From bd57ff7968ebeb568d862e5b58e06f1dc0d759ea Mon Sep 17 00:00:00 2001 From: Thibaud Dauce Date: Mon, 8 Jun 2026 11:45:10 +0200 Subject: [PATCH 1/6] Serve static assets from OVH S3 bucket via cdnURL --- .github/workflows/ci.yml | 51 ++++++++++++++++++++++++++++++++++++++-- nuxt.config.ts | 4 ++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14fff6196..08ddc01f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -340,7 +340,54 @@ jobs: labels: ${{ steps.meta.outputs.labels }} # ============================================ - # JOB 5: Sentry - upload source maps (automatic on main, manual on any branch) + # JOB 5: Assets - push static assets to the OVH S3 bucket (automatic on main, manual on any branch) + # ============================================ + # Runs alongside the Docker build, reusing the same `build-output` artifact. Because the actual + # rollout is triggered by `create-deploy-release.yml` on `workflow_run: CI completed (success)`, + # the new container is only deployed once this job succeeds — so the assets are always on the + # bucket before the new HTML (which references them via `NUXT_APP_CDN_URL`) is served. + upload-assets: + needs: [quality, e2e] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + permissions: + contents: read + env: + # Bucket itself (CORS, public policy, lifecycle) is configured at the infra level. + # PLACEHOLDERS: define these GitHub secrets/vars before enabling on main. + AWS_ACCESS_KEY_ID: ${{ secrets.ASSETS_S3_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.ASSETS_S3_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.ASSETS_S3_REGION }} # e.g. "gra" + S3_ENDPOINT: ${{ vars.ASSETS_S3_ENDPOINT }} # e.g. "https://s3.gra.io.cloud.ovh.net" + S3_BUCKET: ${{ vars.ASSETS_S3_BUCKET }} # bucket name + steps: + - name: Download build artifacts + uses: actions/download-artifact@v8 + with: + name: build-output + path: .output + + - name: Push hashed build assets to S3 + run: | + # `_nuxt/` files are content-hashed, so they are immutable: cache them forever. + # No `--delete`: old chunks must stay available for clients still on the previous + # version during a rolling deploy; stale assets are purged by an infra lifecycle rule. + aws s3 sync .output/public/_nuxt "s3://${S3_BUCKET}/_nuxt" \ + --endpoint-url "$S3_ENDPOINT" \ + --cache-control "public, max-age=31536000, immutable" \ + --no-progress + + - name: Push remaining public files to S3 + run: | + # Non-hashed files (favicon, nuxt_images, ...) get a short TTL so they can be updated. + aws s3 sync .output/public "s3://${S3_BUCKET}" \ + --endpoint-url "$S3_ENDPOINT" \ + --exclude "_nuxt/*" \ + --cache-control "public, max-age=3600" \ + --no-progress + + # ============================================ + # JOB 6: Sentry - upload source maps (automatic on main, manual on any branch) # ============================================ sentry: needs: [quality, e2e] @@ -375,7 +422,7 @@ jobs: ignore_missing: true # ============================================ - # JOB 6: Publish datagouv-components (automatic on main, manual on any branch) + # JOB 7: Publish datagouv-components (automatic on main, manual on any branch) # ============================================ publish-datagouv-components: needs: [quality, e2e] diff --git a/nuxt.config.ts b/nuxt.config.ts index 985d4316e..460bc2d88 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -23,6 +23,10 @@ export default defineNuxtConfig({ devtools: { enabled: true, componentInspector: false }, app: { + // Build assets (`/_nuxt/`) and public files are served from a CDN/S3 bucket in production. + // Empty by default and overridden at runtime via the `NUXT_APP_CDN_URL` env var, so the very + // same build runs with or without the CDN (e.g. E2E tests start the server without it). + cdnURL: '', head: { bodyAttrs: { class: 'datagouv-components font-marianne', From 2ad01ae152abf327803adb13666a9e418618264f Mon Sep 17 00:00:00 2001 From: Thibaud Dauce Date: Wed, 24 Jun 2026 11:37:48 +0200 Subject: [PATCH 2/6] update job name --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33f171fc5..1016b2b43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -397,7 +397,7 @@ jobs: # the new container is only deployed once this job succeeds — so the assets are always on the # bucket before the new HTML (which references them via `NUXT_APP_CDN_URL`) is served. upload-assets: - needs: [quality, e2e] + needs: [quality_and_test, e2e] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' permissions: From 818438a5defacee0b55063c5913454e2c1dc655f Mon Sep 17 00:00:00 2001 From: maudetes Date: Tue, 28 Jul 2026 14:23:25 +0200 Subject: [PATCH 3/6] Try to trigger CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1016b2b43..b010f466d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -399,7 +399,7 @@ jobs: upload-assets: needs: [quality_and_test, e2e] runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/push-static-assets-to-s3' || github.event_name == 'workflow_dispatch' permissions: contents: read env: From 2b35e41a4de48073a4c0556332d0d8555b29145a Mon Sep 17 00:00:00 2001 From: Thibaud Ollagnier Date: Tue, 28 Jul 2026 14:39:59 +0200 Subject: [PATCH 4/6] Update .github/workflows/ci.yml Co-authored-by: maudetes --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b010f466d..1016b2b43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -399,7 +399,7 @@ jobs: upload-assets: needs: [quality_and_test, e2e] runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/push-static-assets-to-s3' || github.event_name == 'workflow_dispatch' + if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' permissions: contents: read env: From 12624d4a880462ace61002aebe1753fe45aa5614 Mon Sep 17 00:00:00 2001 From: Thibaud Dauce Date: Tue, 28 Jul 2026 16:18:14 +0200 Subject: [PATCH 5/6] upload S3 assets as public-read and drop sourcemaps and compressed variants --- .github/workflows/ci.yml | 44 +++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1016b2b43..66439a92d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -395,7 +395,7 @@ jobs: # Runs alongside the Docker build, reusing the same `build-output` artifact. Because the actual # rollout is triggered by `create-deploy-release.yml` on `workflow_run: CI completed (success)`, # the new container is only deployed once this job succeeds — so the assets are always on the - # bucket before the new HTML (which references them via `NUXT_APP_CDN_URL`) is served. + # bucket before the new HTML referencing them is served. upload-assets: needs: [quality_and_test, e2e] runs-on: ubuntu-latest @@ -403,13 +403,13 @@ jobs: permissions: contents: read env: - # Bucket itself (CORS, public policy, lifecycle) is configured at the infra level. - # PLACEHOLDERS: define these GitHub secrets/vars before enabling on main. + # Bucket itself (CORS, lifecycle) is configured at the infra level. Public readability + # is carried by the per-object `public-read` ACL set below, not by a bucket policy. AWS_ACCESS_KEY_ID: ${{ secrets.ASSETS_S3_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.ASSETS_S3_SECRET_KEY }} - AWS_DEFAULT_REGION: ${{ vars.ASSETS_S3_REGION }} # e.g. "gra" - S3_ENDPOINT: ${{ vars.ASSETS_S3_ENDPOINT }} # e.g. "https://s3.gra.io.cloud.ovh.net" - S3_BUCKET: ${{ vars.ASSETS_S3_BUCKET }} # bucket name + AWS_DEFAULT_REGION: ${{ vars.ASSETS_S3_REGION }} + S3_ENDPOINT: ${{ vars.ASSETS_S3_ENDPOINT }} + S3_BUCKET: ${{ vars.ASSETS_S3_BUCKET }} steps: - name: Download build artifacts uses: actions/download-artifact@v8 @@ -417,13 +417,35 @@ jobs: name: build-output path: .output + # ONE-SHOT — REMOVE IN THE FOLLOW-UP COMMIT. + # `aws s3 sync` skips objects that already match, so it would never re-apply the ACL to + # the objects uploaded before `--acl public-read` existed: they would stay 403 forever. + # Wiping first is safe only while the bucket is not served yet — once it is, this step + # would delete the previous version's chunks that old clients still need. + - name: Wipe the bucket before the first ACL-aware upload + run: | + aws s3 rm "s3://${S3_BUCKET}" --recursive \ + --endpoint-url "$S3_ENDPOINT" \ + --only-show-errors + - name: Push hashed build assets to S3 run: | - # `_nuxt/` files are content-hashed, so they are immutable: cache them forever. - # No `--delete`: old chunks must stay available for clients still on the previous - # version during a rolling deploy; stale assets are purged by an infra lifecycle rule. + # `_nuxt/` and `_fonts/` filenames are content-hashed, so they are immutable: cache + # them forever. No `--delete`: old chunks must stay available for clients still on the + # previous version during a rolling deploy; stale assets are purged by an infra + # lifecycle rule. `.map` files stay out of the bucket (they are shipped to Sentry + # instead), and so do `.br`/`.gz`: S3 has no content negotiation, so a browser asking + # for `foo.js` always gets the plain object and the compressed variants are dead weight. aws s3 sync .output/public/_nuxt "s3://${S3_BUCKET}/_nuxt" \ --endpoint-url "$S3_ENDPOINT" \ + --acl public-read \ + --cache-control "public, max-age=31536000, immutable" \ + --exclude "*.map" --exclude "*.br" --exclude "*.gz" \ + --no-progress + + aws s3 sync .output/public/_fonts "s3://${S3_BUCKET}/_fonts" \ + --endpoint-url "$S3_ENDPOINT" \ + --acl public-read \ --cache-control "public, max-age=31536000, immutable" \ --no-progress @@ -432,8 +454,10 @@ jobs: # Non-hashed files (favicon, nuxt_images, ...) get a short TTL so they can be updated. aws s3 sync .output/public "s3://${S3_BUCKET}" \ --endpoint-url "$S3_ENDPOINT" \ - --exclude "_nuxt/*" \ + --acl public-read \ --cache-control "public, max-age=3600" \ + --exclude "_nuxt/*" --exclude "_fonts/*" \ + --exclude "*.map" --exclude "*.br" --exclude "*.gz" \ --no-progress # ============================================ From 5ed5a61b1a0290e3142ba3e4a50fb84b286fcf90 Mon Sep 17 00:00:00 2001 From: Thibaud Dauce Date: Tue, 28 Jul 2026 17:20:04 +0200 Subject: [PATCH 6/6] remove the one-shot bucket wipe now that all objects carry the public-read ACL --- .github/workflows/ci.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66439a92d..d6b64914d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -417,17 +417,6 @@ jobs: name: build-output path: .output - # ONE-SHOT — REMOVE IN THE FOLLOW-UP COMMIT. - # `aws s3 sync` skips objects that already match, so it would never re-apply the ACL to - # the objects uploaded before `--acl public-read` existed: they would stay 403 forever. - # Wiping first is safe only while the bucket is not served yet — once it is, this step - # would delete the previous version's chunks that old clients still need. - - name: Wipe the bucket before the first ACL-aware upload - run: | - aws s3 rm "s3://${S3_BUCKET}" --recursive \ - --endpoint-url "$S3_ENDPOINT" \ - --only-show-errors - - name: Push hashed build assets to S3 run: | # `_nuxt/` and `_fonts/` filenames are content-hashed, so they are immutable: cache