Просмотр исходного кода

Keep server action IDs the same from release to release, so tabs opened before a deploy keep working (#498)

* Keep server action IDs the same from release to release, so tabs opened before a deploy keep working

Next hashes each server action's ID from a key, the action's file path and
its name, and when the build is given no key it makes a random one. Every
image build started from an empty cache, so every release gave every action a
new ID, and a tab opened before the deploy failed its next save with "Server
Action not found" until it was reloaded. The runtime key prod sets did not
help: the IDs are fixed when the image is built.

The image builds now mount an optional NEXT_SERVER_ACTIONS_BUILD_KEY secret
into next build, so the IDs stay put. Without it the build makes its own key,
as before. Next writes the key into the build output, so it is not secret
once built and must not be the runtime key a deployment sets.

With stable IDs an old tab runs the new code with what the old page sends, so
CONTRIBUTING now says to rename an action whose input changes shape or
meaning, which sends those tabs to reload instead.

* Rebuild Next when the server action key changes, rather than reuse a cached build

A build secret is left out of the build cache, so a new key on unchanged
source reused the last Next build and kept its old action IDs. The image
workflows now pass a short hash of the key as NEXT_ACTIONS_FINGERPRINT, which
the build step declares, so a new key means a fresh build and the same key
still reuses the cache.
Bernt Christian Egeland 4 дней назад
Родитель
Сommit
0d00880c55

+ 12 - 0
.github/workflows/deploy-demo.yml

@@ -45,6 +45,14 @@ jobs:
         id: meta
         run: echo "image=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> "$GITHUB_OUTPUT"
 
+      # A short hash of the server action key, so a new key rebuilds Next
+      # instead of reusing a cached build (see the Dockerfile).
+      - name: Fingerprint the server action key
+        id: actions_key
+        env:
+          KEY: ${{ secrets.NEXT_SERVER_ACTIONS_BUILD_KEY }}
+        run: echo "id=$(printf '%s' "$KEY" | sha256sum | cut -c1-12)" >> "$GITHUB_OUTPUT"
+
       - name: Build and push demo image
         uses: docker/build-push-action@v6
         with:
@@ -55,6 +63,10 @@ jobs:
             ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:demo-${{ steps.sha.outputs.short }}
           build-args: |
             APP_VERSION=demo-${{ steps.sha.outputs.short }}
+            NEXT_ACTIONS_FINGERPRINT=${{ steps.actions_key.outputs.id }}
+          # Keeps server action IDs the same from build to build (see the Dockerfile).
+          secrets: |
+            next_actions_key=${{ secrets.NEXT_SERVER_ACTIONS_BUILD_KEY }}
           cache-from: type=gha,scope=demo
           cache-to: type=gha,scope=demo,mode=min
 

+ 5 - 5
.github/workflows/deploy-prod.yml

@@ -78,11 +78,11 @@ jobs:
           # Optional, a repository variable rather than a secret: the largest backup data
           # file a restore reads into memory, in MiB (128 unless set, see .env.example).
           BACKUP_MAX_DATA_FILE_MB: ${{ vars.CLOUD_BACKUP_MAX_DATA_FILE_MB }}
-          # Optional. Next encrypts what a server action closes over with a key
-          # made fresh by every build, so a tab left open across a deploy can
-          # no longer be read by the new container. A fixed key keeps those tabs
-          # working. 32 random bytes, base64: `openssl rand -base64 32`. Left
-          # unset, the build's own key is used, as before.
+          # Optional. Next encrypts what a server action closes over with this
+          # key; left unset, the key the image was built with is used. It does
+          # not keep open tabs working across a deploy: action IDs are fixed
+          # when the image is built, from the build's key (see the Dockerfile).
+          # 32 random bytes, base64: `openssl rand -base64 32`.
           NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.CLOUD_NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
           GOOGLE_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_ID }}
           GOOGLE_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_SECRET }}

+ 12 - 0
.github/workflows/docker-dev.yml

@@ -40,6 +40,14 @@ jobs:
           tags: |
             type=raw,value=dev-${{ steps.sha.outputs.short }}
 
+      # A short hash of the server action key, so a new key rebuilds Next
+      # instead of reusing a cached build (see the Dockerfile).
+      - name: Fingerprint the server action key
+        id: actions_key
+        env:
+          KEY: ${{ secrets.NEXT_SERVER_ACTIONS_BUILD_KEY }}
+        run: echo "id=$(printf '%s' "$KEY" | sha256sum | cut -c1-12)" >> "$GITHUB_OUTPUT"
+
       - name: Build and push Docker image
         uses: docker/build-push-action@v6
         with:
@@ -49,5 +57,9 @@ jobs:
           labels: ${{ steps.meta.outputs.labels }}
           build-args: |
             APP_VERSION=dev-${{ steps.sha.outputs.short }}
+            NEXT_ACTIONS_FINGERPRINT=${{ steps.actions_key.outputs.id }}
+          # Keeps server action IDs the same from build to build (see the Dockerfile).
+          secrets: |
+            next_actions_key=${{ secrets.NEXT_SERVER_ACTIONS_BUILD_KEY }}
           cache-from: type=gha
           cache-to: type=gha,mode=max

+ 13 - 0
.github/workflows/docker-publish.yml

@@ -57,6 +57,14 @@ jobs:
             type=raw,value=latest
             type=raw,value=${{ steps.version.outputs.value }},enable=${{ steps.version.outputs.value != 'development' }}
 
+      # A short hash of the server action key, so a new key rebuilds Next
+      # instead of reusing a cached build (see the Dockerfile).
+      - name: Fingerprint the server action key
+        id: actions_key
+        env:
+          KEY: ${{ secrets.NEXT_SERVER_ACTIONS_BUILD_KEY }}
+        run: echo "id=$(printf '%s' "$KEY" | sha256sum | cut -c1-12)" >> "$GITHUB_OUTPUT"
+
       - name: Build and push Docker image
         uses: docker/build-push-action@v6
         with:
@@ -66,5 +74,10 @@ jobs:
           labels: ${{ steps.meta.outputs.labels }}
           build-args: |
             APP_VERSION=${{ steps.version.outputs.value }}
+            NEXT_ACTIONS_FINGERPRINT=${{ steps.actions_key.outputs.id }}
+          # Keeps server action IDs the same from release to release, so tabs
+          # opened before a deploy keep working (see the Dockerfile).
+          secrets: |
+            next_actions_key=${{ secrets.NEXT_SERVER_ACTIONS_BUILD_KEY }}
           cache-from: type=gha
           cache-to: type=gha,mode=max

+ 1 - 0
CONTRIBUTING.md

@@ -46,6 +46,7 @@ npm run dev               # http://localhost:3000
 - Store dates in UTC, render in the workshop's timezone via `src/lib/timezone.ts` and `src/lib/workshop-datetime.ts`. No `setHours`/`getDay` on raw `Date` server-side.
 - Money carries a currency code; countries ISO 3166-1, languages ISO 639-1.
 - Schema changes: `npx prisma migrate dev --name <change>`, migration folder in the PR. Wrap data migrations in `BEGIN`/`COMMIT`.
+- Server actions keep their IDs from release to release, so a tab opened before a deploy calls the new code with what the old page sends. Changing what an action takes, or what its input means? Give it a new name, and those tabs are asked to reload instead.
 - Tests assert visible behaviour, under `src/__tests__/**`, `*.test.ts(x)`.
 - No secrets. New env vars go in `.env.example` with a comment.
 - Country-specific features (registries, tax rules, parts marketplaces) ship as connectors or settings, never as defaults everyone sees.

+ 17 - 1
Dockerfile

@@ -22,7 +22,23 @@ RUN npx prisma generate
 
 ENV NEXT_TELEMETRY_DISABLED=1
 
-RUN npm run build
+# Server action IDs are hashed from this key, the action's file path and its
+# name. Without one, Next makes a random key for every build, so each release
+# gives every action a new ID and a tab opened before the deploy fails its next
+# save. The same key on every build keeps the IDs, and those tabs keep working.
+# Optional: without the secret the build makes its own key, as before. Next
+# writes the key into the build output, so it is not secret once built; never
+# reuse the key a deployment sets at run time (NEXT_SERVER_ACTIONS_ENCRYPTION_KEY).
+#
+# A secret is left out of the build cache, so a new key on unchanged source
+# would reuse the last build and keep its old IDs. The image workflows pass a
+# fingerprint of the key here, and a new fingerprint means a fresh build.
+ARG NEXT_ACTIONS_FINGERPRINT=none
+RUN --mount=type=secret,id=next_actions_key \
+    if [ -s /run/secrets/next_actions_key ]; then \
+      export NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="$(cat /run/secrets/next_actions_key)"; \
+    fi && \
+    npm run build
 
 # What the container runs beside the server: the Prisma CLI for the migrations
 # at start, and tsx with a full Prisma client for the demo seed. Installed in