diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 16dc0a1..2ddd5b8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,3 +43,19 @@ jobs: steps: - id: deployment uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + # A green deploy is not proof the site serves what we built: #110 shipped a "successful" + # deploy whose artifact silently omitted .well-known/, and only a manual curl caught it. + # Fail the run instead, on the canonical host the Worker actually fronts. + - name: smoke — the canonical URLs answer + run: | + set -uo pipefail + fail=0 + for path in / /feed.xml /.well-known/security.txt /.well-known/pgp-security.asc; do + # -f makes a 4xx an error so --retry-all-errors keeps trying: the Worker caches its + # own 404 for 60s, so the first probe after a deploy can legitimately still miss. + code=$(curl -fsS -L -o /dev/null -w '%{http_code}' \ + --retry 10 --retry-delay 10 --retry-all-errors \ + "https://material-identity.eu${path}") || { code="${code:-000} FAILED"; fail=1; } + printf '%-34s %s\n' "$path" "$code" + done + exit $fail diff --git a/CLAUDE.md b/CLAUDE.md index 7fbd7af..dd53d9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,10 @@ section for the full explanation). code): `pgp-security.asc` (public key only — never a private one) and `security.txt` (RFC 9116). The worker types them and caps their cache at a day. An unnumbered validate check fails the build when `Expires` is missing, past, or over a year out — renew it in place, it is - not under `published/` + not under `published/`. **Nothing emitted into `site/` may start with a dot**: + `upload-pages-artifact` tars with `--exclude=.[^/]*`, so the build writes `site/well-known/` + and the Worker rewrites `/.well-known/` onto it (#110). A build test asserts the site is + dot-free, and `deploy.yml` smoke-tests the live URLs — a green deploy alone proved nothing - `REVIEW.md` — what reviewers check beyond CI; read it before reviewing any publish PR - `standards/` — local-only licensed docs; only its README is committed diff --git a/scripts/build.ts b/scripts/build.ts index c28a6d2..7a4b5ca 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -21,6 +21,10 @@ const SCHEMA_PATH = join(LIB_DIR, '..', 'schema', 'dictionary-entry.schema.json' const CONTEXT_PATH = join(LIB_DIR, '..', 'rdf', 'context.jsonld'); // RFC 8615 well-known URIs, copied wholesale so adding e.g. a security.txt needs no code (#107). const WELL_KNOWN_PATH = join(LIB_DIR, '..', '.well-known'); +// ...but emitted WITHOUT the dot (#110): actions/upload-pages-artifact tars with +// `--exclude=.[^/]*`, so a site/.well-known/ is silently dropped and the URL 404s in production. +// The Worker maps the canonical /.well-known/ onto this path; the origin is never advertised. +const WELL_KNOWN_OUT = 'well-known'; export interface BuildResult { entries: number; @@ -74,7 +78,7 @@ export function build(root: string, out: string): BuildResult { // RDF track steps 1–2 (issue #98): the context is the semantic commitment, the Turtle is a // derived second serialization. The canonical /def/.json is untouched by both. cpSync(CONTEXT_PATH, join(out, 'context.jsonld')); - if (existsSync(WELL_KNOWN_PATH)) cpSync(WELL_KNOWN_PATH, join(out, '.well-known'), { recursive: true }); + if (existsSync(WELL_KNOWN_PATH)) cpSync(WELL_KNOWN_PATH, join(out, WELL_KNOWN_OUT), { recursive: true }); const issued = new Map([...releases].map(([path, release]) => [path, release.date])); writeFileSync(join(out, 'dictionary.ttl'), renderTurtle(repo, refs, issued)); return { entries, out }; diff --git a/test/build.test.ts b/test/build.test.ts index d23732e..8d1dc91 100644 --- a/test/build.test.ts +++ b/test/build.test.ts @@ -332,20 +332,25 @@ test('build publishes the JSON-LD context and the Turtle graph, and the entry JS } }); -test('the tracked .well-known directory is copied into the site byte-for-byte (#107, #109)', () => { +test('the tracked .well-known directory is copied into the site byte-for-byte (#107, #109, #110)', () => { const out = buildGreen(); try { const source = readFileSync(join(here, '..', '.well-known', 'pgp-security.asc')); - assert.deepEqual(readFileSync(join(out, '.well-known', 'pgp-security.asc')), source); + assert.deepEqual(readFileSync(join(out, 'well-known', 'pgp-security.asc')), source); // a public key block, never a private one const text = source.toString('utf8'); assert.match(text, /^-----BEGIN PGP PUBLIC KEY BLOCK-----/); assert.ok(!text.includes('PRIVATE KEY'), 'a private key must never be published'); // security.txt rides along on the same wholesale copy, no builder code of its own - const sec = readFileSync(join(out, '.well-known', 'security.txt'), 'utf8'); + const sec = readFileSync(join(out, 'well-known', 'security.txt'), 'utf8'); assert.deepEqual(sec, readFileSync(join(here, '..', '.well-known', 'security.txt'), 'utf8')); assert.match(sec, /^Canonical: https:\/\/material-identity\.eu\/\.well-known\/security\.txt$/m); + + // Nothing in the emitted site may start with a dot: actions/upload-pages-artifact tars with + // `--exclude=.[^/]*`, so a dot-entry deploys as a 404 while every other test stays green (#110). + const dotted = readdirSync(out, { recursive: true, encoding: 'utf8' }).filter((p) => /(^|\/)\./.test(p)); + assert.deepEqual(dotted, [], `these would be stripped from the Pages artifact: ${dotted.join(', ')}`); } finally { rmSync(out, { recursive: true, force: true }); } diff --git a/test/index-worker.test.ts b/test/index-worker.test.ts index 6cca10f..2575e12 100644 --- a/test/index-worker.test.ts +++ b/test/index-worker.test.ts @@ -98,7 +98,8 @@ test('decide: JSON is the default; HTML only when Accept names text/html', () => test('decide: well-known URIs are typed and cached for a day, never immutable (#107, #109)', () => { const key = decide('/.well-known/pgp-security.asc', '*/*'); - assert.equal(key.originPath, '/.well-known/pgp-security.asc'); + // undotted at the origin — the Pages artifact tar drops dot-directories (#110) + assert.equal(key.originPath, '/well-known/pgp-security.asc'); assert.equal(key.headers['content-type'], 'application/pgp-keys'); // a key can be rotated or revoked, so it must never inherit an entry's immutable caching assert.equal(key.headers['cache-control'], 'public, max-age=86400'); @@ -106,15 +107,21 @@ test('decide: well-known URIs are typed and cached for a day, never immutable (# // RFC 9116 §3 requires security.txt to be served as text/plain with a charset const sec = decide('/.well-known/security.txt', '*/*'); - assert.equal(sec.originPath, '/.well-known/security.txt'); + assert.equal(sec.originPath, '/well-known/security.txt'); assert.equal(sec.headers['content-type'], 'text/plain; charset=utf-8'); assert.equal(sec.headers['cache-control'], 'public, max-age=86400'); - // anything else well-known gets the ceiling and the origin's own type + // anything else well-known gets the ceiling and the origin's own type, nested paths included const other = decide('/.well-known/openpgpkey/hu/abc', '*/*'); + assert.equal(other.originPath, '/well-known/openpgpkey/hu/abc'); assert.equal(other.headers['cache-control'], 'public, max-age=86400'); assert.equal(other.headers['content-type'], undefined); + // no origin path we serve may start with a dot, or the artifact tar drops it again (#110) + for (const p of ['/.well-known/security.txt', '/.well-known/pgp-security.asc', '/.well-known/a/b']) { + assert.ok(!decide(p, '*/*').originPath.includes('/.'), `${p} must not map to a dot-directory`); + } + // .asc / .txt anywhere else are not special-cased assert.equal(decide('/def/whatever.asc', '*/*').headers['content-type'], undefined); assert.equal(decide('/robots.txt', '*/*').headers['content-type'], undefined); diff --git a/worker/index.ts b/worker/index.ts index d58b967..47ff4a2 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -29,11 +29,16 @@ export function decide(pathname: string, accept: string | null): RouteDecision { // RFC 8615 well-known URIs (#107, #109). Cacheable but never immutable like an entry: a key can // be rotated or revoked and security.txt expires, so a day is the ceiling. Pages would serve // .asc as a generic byte stream and .txt without a charset, hence the explicit types. + // + // The origin stores these under `well-known/`, undotted (#110): actions/upload-pages-artifact + // tars with `--exclude=.[^/]*` and silently drops any dot-directory, which 404'd the live URL + // while every local test passed. Rewriting here keeps the canonical, RFC-mandated URL intact — + // mapping a URI onto an origin file is this Worker's whole job. if (pathname.startsWith('/.well-known/')) { const headers: Record = { 'cache-control': 'public, max-age=86400' }; if (pathname.endsWith('.asc')) headers['content-type'] = 'application/pgp-keys'; if (pathname.endsWith('.txt')) headers['content-type'] = 'text/plain; charset=utf-8'; // RFC 9116 §3 - return { originPath: pathname, headers }; + return { originPath: `/well-known/${pathname.slice('/.well-known/'.length)}`, headers }; } // index, pagination, styles, raw origin files: pass through with a short cache