diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml
index 8fe87d8c..cdf8a6cf 100644
--- a/.github/workflows/site.yml
+++ b/.github/workflows/site.yml
@@ -30,6 +30,11 @@ on:
# The docs pin the released CLI version; a release bump must re-run that
# guard in the same push rather than failing the next unrelated change.
- 'package.json'
+ # The decision portal at /decisions/ is built by the committed Action
+ # bundle and checked by this script; a change to either must re-run it.
+ - 'packages/ci/portal/**'
+ - 'packages/ci/dist/portal-action.js'
+ - 'scripts/check-portal-bundle.ts'
workflow_dispatch:
# Least-privilege baseline. Pages deploy privileges are scoped to the deploy job.
@@ -44,6 +49,17 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
+ # Read by the deploy job, which turns the run red after deploying when a
+ # step that is non-blocking on `main` failed (see the portal and rendered
+ # steps below). On pull requests those steps fail this job directly.
+ outputs:
+ portal_failed: >-
+ ${{ steps.portal_route.outcome == 'failure' ||
+ steps.portal.outcome == 'failure' ||
+ steps.portal_check.outcome == 'failure' }}
+ rendered_failed: >-
+ ${{ steps.rendered.outcome == 'failure' ||
+ steps.rendered_self_test.outcome == 'failure' }}
# This job now drives a browser, which is the first step here that can hang
# rather than error. The concurrency group never cancels, so an unbounded
# hang would freeze every later deploy invisibly.
@@ -119,6 +135,62 @@ jobs:
- name: Build site
run: bun run build
+ # This repository's own decision portal, served at adrkit.dev/decisions/
+ # as ADR-0054's rung-2 dogfood (its action item 8). It is built by the
+ # nested Action from this checkout, so it runs the committed
+ # `packages/ci/dist/portal-action.js` exactly as a consumer pinning a
+ # release does. `uses:` ignores the job's `working-directory`; the Action
+ # resolves `dir` and `out` against the workspace.
+ #
+ # It writes into `site/dist` after `astro build`, not into `site/public`
+ # before it. Astro empties `dist` when it builds, so nothing stale
+ # survives; the portal is a directory the Action replaces whole and no
+ # site page reads, unlike the badge files, which `astro dev` must serve;
+ # and Astro has finished, so the guard below can prove no Starlight route
+ # already owns `/decisions/` before the Action replaces it. No `base-url`:
+ # the page needs none, and building without one is what shows the bundle
+ # works at a subpath unchanged (ADR-0054 clause 8).
+ #
+ # The visibility gate passes on `push` and `workflow_dispatch` because
+ # this repository is public and both payloads carry `repository` with
+ # `private: false`. Never set `publish-private` here.
+ #
+ # Exit 1 (corpus errors) succeeds with a warning and publishes, as the
+ # badge step does (clause 9). A refusal or exit 2 writes nothing and fails
+ # the step; on `main` that is non-blocking for the reason stated above the
+ # rendered checks: a missing portal must not withhold the canonical schema
+ # at its `$id` (ADR-0011). The site then deploys without `/decisions/`
+ # (or with an unchecked one), and the deploy job turns the run red after
+ # deploying.
+ - name: Guard — no site route owns /decisions/
+ id: portal_route
+ continue-on-error: ${{ github.event_name != 'pull_request' }}
+ run: |
+ if [ -e dist/decisions ]; then
+ echo "::error::site/dist/decisions exists after astro build; a site page claims /decisions/, which the portal would replace"
+ exit 1
+ fi
+
+ - name: Build the decision portal
+ id: portal
+ if: steps.portal_route.outcome == 'success'
+ continue-on-error: ${{ github.event_name != 'pull_request' }}
+ uses: ./packages/ci/portal
+ with:
+ dir: docs/adr
+ out: site/dist/decisions
+
+ # The Action exiting 0 says it wrote something. This checks what: the page
+ # files, `portal-data.json` at `version: "1"`, as many records as
+ # `adr lint` checked in the badge step above, and no asset reference that
+ # would break at a subpath. Its unit test sabotages each assertion.
+ - name: Check the decision portal bundle
+ id: portal_check
+ if: steps.portal.outcome == 'success'
+ continue-on-error: ${{ github.event_name != 'pull_request' }}
+ working-directory: .
+ run: bun run scripts/check-portal-bundle.ts --bundle site/dist/decisions --lint site/public/lint.json
+
# `astro build` exiting 0 says the pages compile; it says nothing about how
# they render. Every defect fixed in #150 — a rule that silently lost a
# specificity contest, a badge at 1.1:1, a page title ~180px out of line —
@@ -140,8 +212,10 @@ jobs:
# the canonical schema at its `$id` (ADR-0011). Withholding the artifact
# would leave adrkit.dev serving the *previous* schema bytes to every
# `$ref` consumer because a colour moved. The run still goes red and
- # annotates, so the signal survives; only the veto over the schema is
- # removed.
+ # annotates, from the deploy job after `deploy-pages`, so the signal
+ # survives; only the veto over the schema is removed. A failing step in
+ # this job would fail the job, skip the upload, and through
+ # `needs: build` skip the deploy, which is exactly the veto.
- name: Guard — the rendered checks can still fail
id: rendered_self_test
continue-on-error: ${{ github.event_name != 'pull_request' }}
@@ -152,15 +226,6 @@ jobs:
continue-on-error: ${{ github.event_name != 'pull_request' }}
run: bun run check:rendered
- - name: Annotate a rendered failure that did not block the deploy
- if: >-
- github.event_name != 'pull_request' &&
- (steps.rendered.outcome == 'failure' ||
- steps.rendered_self_test.outcome == 'failure')
- run: |
- echo "::error title=Rendered check failed on main::The site deployed anyway so the canonical schema at its \$id stays current (ADR-0011). Fix the rendered regression, or revert — see site/DEPLOYMENT.md."
- exit 1
-
# Only publish an artifact for real deployments (push to main / manual run).
- name: Upload Pages artifact
if: github.event_name != 'pull_request'
@@ -186,3 +251,20 @@ jobs:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
+
+ # After the deploy, not in the build job: a failing step there fails the
+ # job, and `needs: build` would then withhold the deploy, schema included.
+ # The second step uses `always()` (still only after a successful
+ # deploy) so that a rendered failure, which fails this job first, does
+ # not hide a portal failure.
+ - name: Annotate a rendered failure that did not block the deploy
+ if: needs.build.outputs.rendered_failed == 'true'
+ run: |
+ echo "::error title=Rendered check failed on main::The site deployed anyway so the canonical schema at its \$id stays current (ADR-0011). Fix the rendered regression, or revert — see site/DEPLOYMENT.md."
+ exit 1
+
+ - name: Annotate a portal failure that did not block the deploy
+ if: always() && needs.build.outputs.portal_failed == 'true' && steps.deployment.outcome == 'success'
+ run: |
+ echo "::error title=Decision portal failed on main::The site deployed anyway so the canonical schema at its \$id stays current (ADR-0011); /decisions/ is missing or unchecked. Fix it, or revert — see site/DEPLOYMENT.md."
+ exit 1
diff --git a/AGENTS.md b/AGENTS.md
index 0d278033..85e72cd5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1126,7 +1126,9 @@ nothing else: no token, no API call, `contents: read`. The consumer workflow
deploys with `upload-pages-artifact` and `deploy-pages` in a separate job; the
documented workflow is in `site/src/content/docs/ci.mdx`. **Rung 1**: unit,
contract, and mutation tests, and a Node run of the committed bundle against a
-git fixture. Load-bearing:
+git fixture. `site.yml` builds this repository's corpus with it into
+`/decisions/` on adrkit.dev and checks the bundle (`scripts/check-portal-bundle.ts`);
+that is rung 2 only once a deploy has been observed serving it. Load-bearing:
- **Logic is `src/portal-action.ts`, wiring is the entrypoint.** Only
`portal-action-entrypoint.ts` imports `@actions/*`, as for the queue Action,
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e8c217b..7e562662 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,18 @@ Until `1.0.0`, minor releases may include breaking changes
Starlight 0.42's reworked mobile menu, which is meant to open without
JavaScript.
+### Added
+
+- adrkit.dev serves this repository's own decision portal at
+ [`/decisions/`](https://adrkit.dev/decisions/), built on every site deploy by
+ the nested portal Action from the same checkout, and linked from the
+ decision-records index and the CI guide. A new check fails the site build
+ when the bundle is missing a page file, `portal-data.json` is not version
+ `"1"`, its record count differs from `adr lint`'s, or an asset reference
+ would break under a subpath
+ ([ADR-0054](docs/adr/0054-publish-a-static-decision-portal-for-github-pages-built-outside-the-cli-from-the.md)
+ action item 8, #294).
+
## [0.19.0] - 2026-10-10
This release ships the decision portal: a third, build-only nested Action,
diff --git a/scripts/check-portal-bundle.test.ts b/scripts/check-portal-bundle.test.ts
new file mode 100644
index 00000000..9dbb42c6
--- /dev/null
+++ b/scripts/check-portal-bundle.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, test } from 'bun:test';
+import { inspectBundle, lintRecordCount, REQUIRED_FILES } from './check-portal-bundle.ts';
+
+const HTML =
+ '' +
+ 'RecordsGraph';
+
+function data(overrides: Record = {}): string {
+ return JSON.stringify({ version: '1', commit: 'abc', records: [{ id: '0001' }, { id: '0002' }], issues: [], ...overrides });
+}
+
+function bundle(files: Record = {}) {
+ const all: Record = {
+ 'index.html': HTML,
+ 'app.css': 'body{}',
+ 'page/app.mjs': 'export {};',
+ 'vendor/board-layout.mjs': 'export {};',
+ 'vendor/board-view.mjs': 'export {};',
+ 'portal-data.json': data(),
+ ...files,
+ };
+ return (rel: string) => all[rel] ?? null;
+}
+
+describe('inspectBundle', () => {
+ test('accepts a complete version-1 bundle with the expected record count', () => {
+ const report = inspectBundle(bundle(), 2);
+ expect(report.problems).toEqual([]);
+ expect(report.warnings).toEqual([]);
+ expect(report.summary).toBe('version "1", 2 record(s), abc');
+ });
+
+ test('fails on a missing directory: every required file is reported', () => {
+ expect(inspectBundle(() => null, 2).problems).toEqual(REQUIRED_FILES.map((f) => `${f} is missing from the bundle`));
+ });
+
+ test.each(REQUIRED_FILES.map((f) => [f]))('fails when %s is missing', (file) => {
+ expect(inspectBundle(bundle({ [file]: null }), 2).problems).toContain(`${file} is missing from the bundle`);
+ });
+
+ test('fails on a data file that does not parse', () => {
+ expect(inspectBundle(bundle({ 'portal-data.json': '{' }), 2).problems).toEqual(['portal-data.json does not parse as JSON']);
+ });
+
+ test.each([['2'], [1], [null], [undefined]])('fails on version %p', (version) => {
+ expect(inspectBundle(bundle({ 'portal-data.json': data({ version }) }), 2).problems[0]).toContain('expected "1"');
+ });
+
+ test('fails on a record count that differs from adr lint', () => {
+ expect(inspectBundle(bundle(), 3).problems).toEqual(['portal-data.json has 2 record(s); adr lint checked 3']);
+ });
+
+ test('fails when there is no lint count to compare against', () => {
+ expect(inspectBundle(bundle(), null).problems[0]).toContain('no record count from adr lint');
+ });
+
+ test('fails on a data file with no records array', () => {
+ expect(inspectBundle(bundle({ 'portal-data.json': data({ records: {} }) }), 2).problems).toEqual([
+ 'portal-data.json has no records array',
+ ]);
+ });
+
+ // Clause 9: an exit-1 corpus still publishes, and a record that does not parse
+ // is counted by lint but cannot be a portal record.
+ test('downgrades a count mismatch to a warning when the corpus has error findings', () => {
+ const errored = data({ issues: [{ code: 'frontmatter-parse', severity: 'error', message: 'x', path: 'docs/adr/0003-x.md' }] });
+ const report = inspectBundle(bundle({ 'portal-data.json': errored }), 3);
+ expect(report.problems).toEqual([]);
+ expect(report.warnings).toHaveLength(1);
+ });
+
+ test('a warn-severity issue does not excuse a count mismatch', () => {
+ const warned = data({ issues: [{ code: 'x', severity: 'warn', message: 'x', path: null }] });
+ expect(inspectBundle(bundle({ 'portal-data.json': warned }), 3).problems).toHaveLength(1);
+ });
+
+ // Clause 8: the bundle must load at /decisions/, not only at a domain root.
+ test.each([['/app.css'], ['//cdn.example/app.css'], ['https://adrkit.dev/decisions/app.css']])(
+ 'fails on a non-relative asset reference %p',
+ (href) => {
+ const html = HTML.replace('./app.css', href);
+ expect(inspectBundle(bundle({ 'index.html': html }), 2).problems).toEqual([
+ `index.html references "${href}", which is not relative or a hash route; it breaks at a subpath`,
+ ]);
+ },
+ );
+});
+
+test('accepts a bare relative reference', () => {
+ expect(inspectBundle(bundle({ 'index.html': HTML.replace('./app.css', 'app.css') }), 2).problems).toEqual([]);
+});
+
+describe('lintRecordCount', () => {
+ test('reads the integer checked field', () => {
+ expect(lintRecordCount('{"checked":59,"findings":[]}')).toBe(59);
+ });
+
+ test.each([[null], ['not json'], ['{}'], ['{"checked":true}'], ['{"checked":"59"}'], ['{"checked":-1}'], ['{"checked":1.5}'], ['null']])(
+ 'returns null for %p',
+ (text) => {
+ expect(lintRecordCount(text)).toBeNull();
+ },
+ );
+});
diff --git a/scripts/check-portal-bundle.ts b/scripts/check-portal-bundle.ts
new file mode 100644
index 00000000..d970ced0
--- /dev/null
+++ b/scripts/check-portal-bundle.ts
@@ -0,0 +1,157 @@
+/**
+ * Fail when the decision portal that `site.yml` builds for adrkit.dev is not the
+ * bundle it claims to be
+ * ([ADR-0054](../docs/adr/0054-publish-a-static-decision-portal-for-github-pages-built-outside-the-cli-from-the.md)
+ * action item 8, the rung-2 dogfood).
+ *
+ * The portal Action exiting 0 says it wrote something into `out`. It does not say
+ * the page files are there, that the data file is the version-1 contract, that it
+ * carries this repository's records, or that the page still loads from a subpath.
+ * This checks those four things against the built directory, before upload:
+ *
+ * 1. `index.html`, `app.css`, `page/app.mjs`, and `portal-data.json` exist.
+ * 2. `portal-data.json` parses and its `version` is exactly `"1"`.
+ * 3. Its `records` count equals the record count `adr lint --json` reported for
+ * the same corpus in the same job (`checked`). The CLI is the oracle rather
+ * than a filename glob here, because a glob would restate core's record
+ * grammar, which is the drift `check:site-grammar` exists to stop. When the
+ * data file carries an error-severity issue, a mismatch is a warning: an
+ * exit-1 corpus still publishes (clause 9), and a record that does not parse
+ * can be counted by lint and absent from the portal.
+ * 4. No `src` or `href` in `index.html` is root-absolute (`/…`), scheme-relative
+ * (`//…`), or absolute (`https:…`). Such a reference loads at a domain root
+ * and breaks at `/decisions/` (clause 8); relative paths and hash routes
+ * (`#/…`) work at any path.
+ *
+ * Imports only Node builtins.
+ *
+ * bun run scripts/check-portal-bundle.ts --bundle site/dist/decisions --lint site/public/lint.json
+ */
+
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+/** Files the page needs to load; a bundle missing any of them renders nothing. */
+// page/app.mjs imports the two vendor modules statically (packages/portal/src/page/app.mjs);
+// if either is missing the browser aborts the page module and the portal renders nothing.
+export const REQUIRED_FILES = [
+ 'index.html',
+ 'app.css',
+ 'page/app.mjs',
+ 'vendor/board-layout.mjs',
+ 'vendor/board-view.mjs',
+ 'portal-data.json',
+] as const;
+
+export interface BundleReport {
+ readonly problems: string[];
+ readonly warnings: string[];
+ /** One line for the log when there are no problems. */
+ readonly summary: string;
+}
+
+/** `read(rel)` returns the file's text, or `null` when it does not exist. */
+export type ReadFile = (relativePath: string) => string | null;
+
+const ATTRIBUTE = /\b(?:src|href)\s*=\s*"([^"]*)"/gi;
+/** `https:`, `data:`, `javascript:`…: anything that names a scheme is not a relative reference. */
+const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
+
+/** Pure: the problems and warnings for one bundle, given the CLI's record count. */
+export function inspectBundle(read: ReadFile, expectedRecords: number | null): BundleReport {
+ const problems: string[] = [];
+ const warnings: string[] = [];
+
+ const contents = new Map();
+ for (const file of REQUIRED_FILES) {
+ const text = read(file);
+ if (text === null) problems.push(`${file} is missing from the bundle`);
+ else contents.set(file, text);
+ }
+
+ const html = contents.get('index.html');
+ if (html !== undefined) {
+ for (const match of html.matchAll(ATTRIBUTE)) {
+ const value = match[1] as string;
+ if (value.startsWith('/') || SCHEME.test(value)) {
+ problems.push(`index.html references "${value}", which is not relative or a hash route; it breaks at a subpath`);
+ }
+ }
+ }
+
+ let summary = '';
+ const raw = contents.get('portal-data.json');
+ if (raw !== undefined) {
+ let data: unknown;
+ try {
+ data = JSON.parse(raw);
+ } catch {
+ problems.push('portal-data.json does not parse as JSON');
+ }
+ if (data !== undefined) {
+ const record = (data ?? {}) as { version?: unknown; records?: unknown; issues?: unknown; commit?: unknown };
+ if (record.version !== '1') {
+ problems.push(`portal-data.json has version ${JSON.stringify(record.version)}, expected "1"`);
+ }
+ if (!Array.isArray(record.records)) {
+ problems.push('portal-data.json has no records array');
+ } else if (expectedRecords === null) {
+ problems.push('no record count from adr lint to compare against; the corpus report was not published');
+ } else if (record.records.length !== expectedRecords) {
+ const hasErrors =
+ Array.isArray(record.issues) &&
+ record.issues.some((issue) => (issue as { severity?: unknown } | null)?.severity === 'error');
+ const message = `portal-data.json has ${record.records.length} record(s); adr lint checked ${expectedRecords}`;
+ if (hasErrors) warnings.push(`${message} (the corpus has error findings, so a record can be absent)`);
+ else problems.push(message);
+ }
+ if (Array.isArray(record.records)) {
+ const commit = typeof record.commit === 'string' ? record.commit : 'no commit';
+ summary = `version ${JSON.stringify(record.version)}, ${record.records.length} record(s), ${commit}`;
+ }
+ }
+ }
+
+ return { problems, warnings, summary };
+}
+
+/** The integer `checked` of an `adr lint --json` report, or `null`. */
+export function lintRecordCount(text: string | null): number | null {
+ if (text === null) return null;
+ try {
+ const checked = (JSON.parse(text) as { checked?: unknown } | null)?.checked;
+ return typeof checked === 'number' && Number.isSafeInteger(checked) && checked >= 0 ? checked : null;
+ } catch {
+ return null;
+ }
+}
+
+function readOrNull(path: string): string | null {
+ try {
+ return readFileSync(path, 'utf8');
+ } catch {
+ return null;
+ }
+}
+
+function argument(name: string): string {
+ const index = process.argv.indexOf(name);
+ const value = index === -1 ? undefined : process.argv[index + 1];
+ if (value === undefined || value.startsWith('--')) {
+ console.error(`check-portal-bundle: ${name} is required`);
+ process.exit(2);
+ }
+ return value;
+}
+
+if (import.meta.main) {
+ const bundle = argument('--bundle');
+ const lint = argument('--lint');
+ const report = inspectBundle((rel) => readOrNull(join(bundle, rel)), lintRecordCount(readOrNull(lint)));
+ for (const warning of report.warnings) console.log(`::warning::check-portal-bundle: ${warning}`);
+ if (report.problems.length > 0) {
+ for (const problem of report.problems) console.error(`check-portal-bundle: ${problem}`);
+ process.exit(1);
+ }
+ console.log(`check-portal-bundle: ok — ${bundle}: ${report.summary}`);
+}
diff --git a/site/DEPLOYMENT.md b/site/DEPLOYMENT.md
index ba2982b5..850956a0 100644
--- a/site/DEPLOYMENT.md
+++ b/site/DEPLOYMENT.md
@@ -37,6 +37,25 @@ committed under `schema/versions/` (ADR-0011 action item 6).
When `SCHEMA_VERSION` moves, copy the outgoing `schema/adr.schema.json` into
`schema/versions/v/` in the same change; the planner refuses a canonical
version that would repoint a retained one.
+- **Decision portal:** after `astro build`, the nested portal Action
+ (`uses: ./packages/ci/portal`, so the committed
+ `packages/ci/dist/portal-action.js` runs exactly as a consumer's pinned copy
+ does) builds this repository's `docs/adr` into `dist/decisions/`, served at
+ `https://adrkit.dev/decisions/`
+ ([ADR-0054](../docs/adr/0054-publish-a-static-decision-portal-for-github-pages-built-outside-the-cli-from-the.md),
+ action item 8). It goes into `dist` after the build rather than `public`
+ before it because Astro empties `dist` when it builds, and a guard step fails
+ if a site page already produced `dist/decisions/`, which the Action would
+ replace. Assets are relative and routes are hash routes (`#/adr/0054`), so no
+ `base-url` is passed. `scripts/check-portal-bundle.ts` then checks the page
+ files, `portal-data.json` at `version: "1"`, its record count against the
+ `checked` count in `lint.json`, and that no asset reference is absolute. A
+ corpus with error findings (exit 1) still publishes, with a banner. On pull
+ requests a portal failure fails the build; on `main` it does not withhold the
+ schema deploy: the site deploys without (or with an unchecked) `/decisions/`
+ and the deploy job turns the run red afterwards. Because that step runs in
+ the `github-pages` environment's job, the Deployments page then shows the
+ deployment as failed even though the site is live.
- **Custom domain persistence:** [`public/CNAME`](./public/CNAME) contains
`adrkit.dev`, so the custom domain survives every deploy.
@@ -120,6 +139,10 @@ curl -s https://adrkit.dev/schema/adr/v0.2.0/adr.schema.json | head -3
curl -s https://adrkit.dev/schema/adr/v0.2.0/adr.schema.json \
| grep '"$id"'
+# The decision portal loads its data file from its own subpath
+curl -s https://adrkit.dev/decisions/portal-data.json \
+ | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["version"], d["commit"], len(d["records"]))'
+
# A retained prior version still serves its published bytes
curl -s https://adrkit.dev/schema/adr/v0.1.0/adr.schema.json | shasum -a 256
# 1e1841151174cc5a8ed22dadae070f087477e5068bd928d5292c3acd2e2681cc
@@ -147,8 +170,14 @@ git push origin HEAD:refs/heads/main # if you have push access to main
required status checks, bypassable by repository role), so if a direct push is
rejected, open the revert as a pull request and merge it normally. Note that the
site `build` job is **not** in `main`'s required-checks list, so a red rendered
-check does not block the merge — but `deploy` needs `build`, so it does hold the
-publish.
+check does not block the merge. On `main` a failed rendered check (or portal
+step) does not hold the publish either: those steps are `continue-on-error`
+there, the build job passes their outcomes to the deploy job as outputs, and the
+deploy job reports them with an `exit 1` only after `deploy-pages` has
+published. The run is red and the `github-pages` deployment shows as failed
+while the new site, schema included, is live. A failure in any other build step
+(install, schema sync, `astro build`) still fails `build`, and `deploy` needs
+`build`, so that does hold the publish.
Two things the timing depends on, neither obvious:
@@ -162,7 +191,8 @@ Two things the timing depends on, neither obvious:
If the build job itself is red for an environment reason — no browser on the
runner, a `playwright-core` or Chrome bump — the rendered steps are already
-non-blocking on `main` and annotate instead, so the deploy proceeds. Do not
+non-blocking on `main`: the deploy proceeds and the deploy job annotates
+afterwards. On pull requests they fail the build directly. Do not
delete the steps to unblock a release; that removes the check silently and
nothing records it.
@@ -175,6 +205,8 @@ on the ref it ran on*. Deploying an older ref therefore also republishes:
- an **older schema** at the `$id` that other tools fetch over HTTP, and
- **stale badge projections**, which degrade to plausible-but-wrong numbers
rather than to an error.
+- a **stale decision portal** at `/decisions/`, built from the older corpus
+ and by the older portal Action.
Neither failure announces itself. `check:schema` compares the served copy against
the canonical file *within the deployed ref*, so it passes on an old ref by
diff --git a/site/scripts/gen-adr-pages.ts b/site/scripts/gen-adr-pages.ts
index a744e98a..14b41490 100644
--- a/site/scripts/gen-adr-pages.ts
+++ b/site/scripts/gen-adr-pages.ts
@@ -344,7 +344,7 @@ function renderIndex(docs: AdrDoc[]): string {
.join('\n');
const yaml = stringifyYaml(frontmatter).trimEnd();
- return `---\n${yaml}\n---\n\nadrkit dogfoods its own format: every decision in this project is a typed\ndecision record in [\`docs/adr/\`](${GITHUB_TREE}).\nThese pages are generated from those source files — the records themselves are\nnever edited to fit this site.\n\n| # | Decision | Status |\n| --- | --- | --- |\n${rows}\n\nSee the [ADR template](/adr/0000-template/) for the copy-ready starting point.\n`;
+ return `---\n${yaml}\n---\n\nadrkit dogfoods its own format: every decision in this project is a typed\ndecision record in [\`docs/adr/\`](${GITHUB_TREE}).\nThese pages are generated from those source files — the records themselves are\nnever edited to fit this site.\n\nThe same corpus is also served as a [decision portal](/decisions/): the\nsupersession graph, the review queue, and which decisions govern each path,\nbuilt by adrkit's own portal Action on every deploy.\n\n| # | Decision | Status |\n| --- | --- | --- |\n${rows}\n\nSee the [ADR template](/adr/0000-template/) for the copy-ready starting point.\n`;
}
function main(): void {
diff --git a/site/src/content/docs/ci.mdx b/site/src/content/docs/ci.mdx
index 92f51101..0f0e81e4 100644
--- a/site/src/content/docs/ci.mdx
+++ b/site/src/content/docs/ci.mdx
@@ -252,6 +252,11 @@ So a corpus with error findings still publishes, and says so on every page,
while a corpus that cannot be read publishes nothing and leaves the last
deployed site in place.
+adrkit builds its own corpus this way on every deploy of this site, from the
+committed Action in the same checkout, and serves it at
+[adrkit.dev/decisions/](/decisions/). The bundle needs no `base-url` to work
+under a subpath: its assets are relative and its routes are hash routes.
+