Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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 "/*"
66 changes: 0 additions & 66 deletions .github/workflows/pages.yml

This file was deleted.

114 changes: 114 additions & 0 deletions infra/deploy.md
Original file line number Diff line number Diff line change
@@ -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/<name>.json` (×16) | `schemas/<name>.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 <DIST_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'`.
31 changes: 13 additions & 18 deletions site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading