From c7013b86b7d670f4abca8c93689b389da0f28573 Mon Sep 17 00:00:00 2001 From: yoshi49535 Date: Fri, 24 Jul 2026 16:39:07 +0900 Subject: [PATCH 1/2] ci: serve dplaax.dev wire surface from S3 + CloudFront GitHub Pages derives Content-Type from the file extension only and cannot override response headers, so the frozen extensionless wire URI /vc/v1 was served as application/octet-stream. Move the apex wire surface (/vc/v1, /schemas/*, /oauth/grant-type/did) to a dedicated S3 bucket behind CloudFront, where object metadata sets application/ld+json. The served tree is assembled from the same single sources as the Pages workflow (contexts/v1.jsonld, schemas/*.json, site/) and guarded by the existing sha256 pin. Ships disabled (if: false) until the bucket and distribution exist; pages.yml is retired after the DNS cutover is verified. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 116 +++++++++++++++++++++++++++++++++++ infra/deploy.md | 114 ++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 infra/deploy.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..c2e032e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,116 @@ +# Deploys the dplaax.dev wire surface to S3 + CloudFront. +# +# dplaax.dev serves the protocol's machine-readable wire identifiers — the +# JSON-LD context at /vc/v1, the JSON Schemas under /schemas/, and the OAuth +# grant-type namespace document at /oauth/grant-type/did. GitHub Pages derives +# Content-Type from the file extension only and cannot override response +# headers, so the extensionless /vc/v1 was served as application/octet-stream. +# S3 object metadata lets us serve it as application/ld+json — the reason this +# surface moves off Pages. See infra/deploy.md. +# +# The served tree is ASSEMBLED here from the single source of truth: site/ holds +# the hand-authored apex pages (root + 404 redirect to www.dplaax.dev, the +# grant-type doc); the wire identifiers are copied byte-for-byte from their +# canonical locations (contexts/v1.jsonld -> /vc/v1, schemas/*.json -> +# /schemas/*). Nothing is duplicate-committed under site/. +# +# Disabled until the AWS OIDC role, bucket, and distribution exist. To activate, +# change `if: false` and populate repo secrets: +# - AWS_ROLE_TO_ASSUME (OIDC role with S3 + CloudFront perms, scoped to +# this bucket/distribution only) +# - AWS_REGION +# - S3_BUCKET (wire.dplaax.dev) +# - CLOUDFRONT_DISTRIBUTION_ID +name: deploy + +on: + push: + branches: [main] + workflow_dispatch: + +# One publish at a time; don't cancel an in-flight deploy. +concurrency: + group: deploy + cancel-in-progress: false + +jobs: + deploy: + if: false # disabled — see infra/deploy.md before enabling + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + env: + S3_BUCKET: ${{ secrets.S3_BUCKET }} + steps: + - uses: actions/checkout@v4 + + - name: Assemble the served tree + run: | + set -euo pipefail + rm -rf _site + mkdir -p _site/vc _site/schemas _site/oauth/grant-type + # hand-authored apex pages. CNAME/.nojekyll are Pages-only control + # files and are intentionally NOT shipped to S3. + cp site/index.html _site/index.html + cp site/404.html _site/404.html + # identifier paths are served as extensionless objects (their frozen + # URIs carry no extension); Content-Type is set explicitly below. + cp site/oauth/grant-type/did/index.html _site/oauth/grant-type/did + cp contexts/v1.jsonld _site/vc/v1 + cp schemas/*.json _site/schemas/ + + - name: Guard byte-exact context (must match the sha256 pin) + run: | + set -euo pipefail + expected=9716bca789bdb1042451746800cc463a616a57817008001a3a895e88c0aff25f + actual=$(sha256sum _site/vc/v1 | cut -d' ' -f1) + if [ "$actual" != "$expected" ]; then + echo "::error::served /vc/v1 sha256 $actual != pinned $expected (contexts/v1.jsonld drifted from the signing-scope pin)" + exit 1 + fi + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Sync to S3 + run: | + set -euo pipefail + # Defense-in-depth for the highest-consequence failure: this step runs + # --delete, so refuse any bucket that is not a dedicated wire bucket. A + # secret misconfigured to an LP bucket (www.*) would otherwise erase the + # landing page — the exact catastrophe this migration exists to prevent. + case "$S3_BUCKET" in + wire.*) ;; + *) echo "::error::refusing to sync --delete a non-wire bucket: $S3_BUCKET"; exit 1 ;; + esac + # Bulk sync of the extension-bearing files; Content-Type maps from the + # extension (.html -> text/html, .json -> application/json). The + # extensionless identifier objects are EXCLUDED: sync would upload them + # as application/octet-stream and only a later step would repair the + # type — an interruption (or a CloudFront cache miss in that window) + # could serve or pin the wrong type. They are uploaded exactly once, + # below, with the right type. --exclude also applies to the delete + # scan, so --delete leaves the excluded keys alone. + aws s3 sync _site/ "s3://${S3_BUCKET}/" --delete \ + --exclude "vc/v1" \ + --exclude "oauth/grant-type/did" + + - name: Upload the extensionless identifier objects + run: | + set -euo pipefail + # Sole upload path for these keys (excluded from the sync above): a + # single write carrying the correct media type, so the origin never + # holds them as application/octet-stream, even transiently. + aws s3 cp _site/vc/v1 "s3://${S3_BUCKET}/vc/v1" \ + --content-type application/ld+json + aws s3 cp _site/oauth/grant-type/did "s3://${S3_BUCKET}/oauth/grant-type/did" \ + --content-type text/html + + - name: Invalidate CloudFront + run: | + aws cloudfront create-invalidation \ + --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \ + --paths "/*" diff --git a/infra/deploy.md b/infra/deploy.md new file mode 100644 index 0000000..8bc244b --- /dev/null +++ b/infra/deploy.md @@ -0,0 +1,114 @@ +# Deployment — dplaax.dev wire surface (S3 + CloudFront) + +`dplaax.dev` serves the protocol's **machine-readable wire identifiers**. This +is a different surface from the human landing page at `www.dplaax.dev` (that +lives in `dplaax/site`, with its own bucket and distribution). **Nothing here is +executed automatically** — `.github/workflows/deploy.yml` ships disabled; this +file documents the target topology. + +## Why not GitHub Pages + +Pages derives `Content-Type` from the file extension only and offers no way to +override response headers. The frozen wire URI `/vc/v1` is extensionless, so +Pages serves it as `application/octet-stream`. S3 object metadata lets us set +`application/ld+json`. That single constraint is why this surface is on +CloudFront + S3 rather than Pages. + +## Topology + +```text +Route 53 CloudFront S3 +───────── ────────── ── +dplaax.dev ──ALIAS──▶ Distribution ──OAC──▶ s3://wire.dplaax.dev + (private bucket, versioning on) +``` + +`www.dplaax.dev` (the LP) is a **separate** bucket and distribution. Do not +point `dplaax.dev` at the LP distribution, and never `aws s3 sync --delete` an +LP build into `wire.dplaax.dev` — that would erase the identifier documents. + +## S3 bucket + +- Name: `wire.dplaax.dev` +- Public access: **blocked** (CloudFront reads via OAC) +- Static website hosting: **off** (CloudFront + OAC, not the S3 website endpoint) +- Versioning: **on** (recover from an accidental sync delete) +- Holds only the assembled wire tree — no other content shares this bucket. + +## Served object layout + +| path (URI) | S3 key | Content-Type | +| ---------------------------- | ---------------------- | --------------------- | +| `/vc/v1` | `vc/v1` | `application/ld+json` | +| `/schemas/.json` (×16) | `schemas/.json` | `application/json` | +| `/oauth/grant-type/did` | `oauth/grant-type/did` | `text/html` | +| `/` (root) | `index.html` | `text/html` | +| 404 | `404.html` | `text/html` | + +The identifier paths (`/vc/v1`, `/oauth/grant-type/did`) are stored as +**extensionless objects** so their frozen URIs resolve directly, without a +directory-index rewrite. The deploy workflow sets their `Content-Type` +explicitly (S3 would otherwise guess `application/octet-stream`). + +## CloudFront distribution + +- Origin: `wire.dplaax.dev` via Origin Access Control (OAC) +- Default root object: `index.html` +- Custom error responses: `403`/`404` → `/404.html` (return HTTP 404) +- Compress objects: on; HTTP/2 + HTTP/3 +- TLS: ACM certificate in `us-east-1` for `dplaax.dev` +- Alternate domain name (CNAME): `dplaax.dev` + +## Migration from GitHub Pages (one-time) + +`dplaax.dev` currently serves this tree from Pages +(`.github/workflows/pages.yml`). Cut over without dropping the live identifier: + +1. Provision the bucket, cert, and distribution above. +2. Run the deploy (enable `deploy.yml`, or run the manual fallback) and verify on + the distribution domain (`d….cloudfront.net`) **before** touching DNS: + byte-exact `/vc/v1` (sha256 `9716bca…`) **and** + `Content-Type: application/ld+json`. +3. Repoint `dplaax.dev` A/AAAA from the Pages IPs to the CloudFront alias. +4. Re-verify against `https://dplaax.dev/…`, then **retire `pages.yml`**. + +## Manual deploy fallback + +```bash +# from the repo root, with AWS creds for the wire account. +# fail fast: a partial assembly must never reach a --delete sync of the live +# bucket, and drift must never pass under the frozen URI. +set -euo pipefail + +# assemble step mirrors .github/workflows/deploy.yml: +rm -rf _site && mkdir -p _site/vc _site/schemas _site/oauth/grant-type +cp site/index.html _site/index.html +cp site/404.html _site/404.html +cp site/oauth/grant-type/did/index.html _site/oauth/grant-type/did +cp contexts/v1.jsonld _site/vc/v1 +cp schemas/*.json _site/schemas/ + +# the same byte-exact guard the workflow enforces +expected=9716bca789bdb1042451746800cc463a616a57817008001a3a895e88c0aff25f +[ "$(shasum -a 256 _site/vc/v1 | cut -d' ' -f1)" = "$expected" ] \ + || { echo "vc/v1 drifted from the signing-scope pin"; exit 1; } + +# extensionless identifier keys are excluded from the sync and uploaded once +# with the right type (same rationale as the workflow: no octet-stream window) +aws s3 sync _site/ s3://wire.dplaax.dev/ --delete \ + --exclude "vc/v1" --exclude "oauth/grant-type/did" +aws s3 cp _site/vc/v1 s3://wire.dplaax.dev/vc/v1 --content-type application/ld+json +aws s3 cp _site/oauth/grant-type/did s3://wire.dplaax.dev/oauth/grant-type/did --content-type text/html +aws cloudfront create-invalidation --distribution-id --paths "/*" +``` + +## CI deployment + +`.github/workflows/deploy.yml` ships disabled (`if: false`). To enable: + +1. Create an AWS IAM role trusted by GitHub OIDC + (`token.actions.githubusercontent.com`) with S3 sync + CloudFront + `create-invalidation` permissions on **this** bucket / distribution only. +2. Add repo secrets: `AWS_ROLE_TO_ASSUME`, `AWS_REGION`, `S3_BUCKET` + (`wire.dplaax.dev`), `CLOUDFRONT_DISTRIBUTION_ID`. +3. Change `if: false` to `if: github.ref == 'refs/heads/main'`. From d7681e3acac7ae60b81c6131932fe870d1573604 Mon Sep 17 00:00:00 2001 From: yoshi49535 Date: Fri, 24 Jul 2026 17:26:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?ci:=20retire=20pages.yml=20=E2=80=94=20dpla?= =?UTF-8?q?ax.dev=20now=20serves=20from=20CloudFront?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The apex A/AAAA records moved from the Pages IPs to the wire distribution (E3OEG9PWPHUC8W / wire.dplaax.dev bucket) and the full §5 battery passed against production: /vc/v1 byte-exact under the signing-scope pin AND Content-Type: application/ld+json — the header Pages could not set — plus all 16 schemas byte-exact, the grant-type page, and root/404. The last Pages deployment stays up untouched as the DNS rollback target; only the workflow that would redeploy it is removed. site/README.md now documents the S3 publishing path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pages.yml | 66 ------------------------------------- site/README.md | 31 ++++++++--------- 2 files changed, 13 insertions(+), 84 deletions(-) delete mode 100644 .github/workflows/pages.yml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index 32242f8..0000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,66 +0,0 @@ -# Publishes dplaax.dev via GitHub Pages. -# -# The served tree is ASSEMBLED at deploy from the single source of truth: -# `site/` holds the hand-authored pages (grant-type namespace doc, the apex -# root/404 redirects to www.dplaax.dev, CNAME); the machine-readable wire -# identifiers are copied in byte-for-byte from their canonical locations -# (`contexts/v1.jsonld` -> /vc/v1, `schemas/*.json` -> /schemas/*). Nothing is -# duplicate-committed under site/, so there is no drift to keep in sync. -# -# NOTE: Pages only serves once the repo is public (free plan). Until then this -# workflow builds but does not publish anything reachable. -name: pages - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -# One publish at a time; don't cancel an in-flight deploy. -concurrency: - group: pages - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/configure-pages@v5 - - name: Assemble the served tree - run: | - set -euo pipefail - rm -rf _site - mkdir -p _site/vc _site/schemas - # hand-authored pages + CNAME + .nojekyll - cp -R site/. _site/ - # wire identifiers, copied byte-exact from the canonical sources - cp contexts/v1.jsonld _site/vc/v1 - cp schemas/*.json _site/schemas/ - - name: Guard byte-exact context (must match the sha256 pin) - run: | - set -euo pipefail - expected=9716bca789bdb1042451746800cc463a616a57817008001a3a895e88c0aff25f - actual=$(sha256sum _site/vc/v1 | cut -d' ' -f1) - if [ "$actual" != "$expected" ]; then - echo "::error::served /vc/v1 sha256 $actual != pinned $expected (contexts/v1.jsonld drifted from the signing-scope pin)" - exit 1 - fi - - uses: actions/upload-pages-artifact@v3 - with: - path: _site - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment - uses: actions/deploy-pages@v4 diff --git a/site/README.md b/site/README.md index b8d5a33..70bd51b 100644 --- a/site/README.md +++ b/site/README.md @@ -27,30 +27,25 @@ redirect. not places in a website. Human documentation sections may exist alongside (e.g. `/spec/...`) but identifiers do not point into them. - **Self-contained pages only**: no external assets, no build step that - rewrites bytes (no minification). `.nojekyll` keeps branch-based Pages - publishing from touching content. + rewrites bytes (no minification). ## Publishing -`.github/workflows/pages.yml` deploys to GitHub Pages (source: GitHub -Actions) on push to `develop`. It **assembles** the served tree rather than -serving `site/` verbatim: it copies `site/` as-is, then copies the +`.github/workflows/deploy.yml` publishes to S3 + CloudFront (bucket +`wire.dplaax.dev` behind an OAC distribution — topology in +`infra/deploy.md`). It **assembles** the served tree rather than serving +`site/` verbatim: it copies the hand-authored pages, then copies the machine-readable identifiers in byte-for-byte from their canonical sources — `contexts/v1.jsonld` → `/vc/v1`, `schemas/*.json` → `/schemas/*.json` — so -those payloads live in exactly one place and cannot drift. A build step fails +those payloads live in exactly one place and cannot drift. A guard step fails the deploy if the served `/vc/v1` sha256 ever diverges from the signing-scope -pin (`9716bca…`). The `CNAME` file binds `dplaax.dev`. - -Not live until the repo is public: on the free plan GitHub Pages does not -serve a private repo. The workflow + `CNAME` make it ready; going public -activates it. Until then nothing breaks — no identifier requires -dereferencing (the context is embedded + sha256-pinned in provin.oss). - -Caveat: GitHub Pages cannot set a custom `Content-Type`, so `/vc/v1` -(extensionless) is served with the host default rather than -`application/ld+json`. Acceptable because the context is never dereferenced -at runtime; if a strict media type is ever required, move the apex to an -S3+CloudFront origin (as `provin.dev` already uses). +pin (`9716bca…`). + +S3 object metadata is what lets the extensionless identifier paths carry +their correct media types (`/vc/v1` → `application/ld+json`) — the reason +this surface moved off GitHub Pages, which derives `Content-Type` from the +file extension alone. The Pages-era control files (`CNAME`, `.nojekyll`) +remain in `site/` as inert history but are not shipped to S3. ## Current documents