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
104 changes: 93 additions & 11 deletions .github/workflows/site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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 —
Expand All @@ -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' }}
Expand All @@ -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'
Expand All @@ -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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
104 changes: 104 additions & 0 deletions scripts/check-portal-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, test } from 'bun:test';
import { inspectBundle, lintRecordCount, REQUIRED_FILES } from './check-portal-bundle.ts';

const HTML =
'<link rel="stylesheet" href="./app.css"><script type="module" src="./page/app.mjs"></script>' +
'<a href="#/">Records</a><a href="#/graph">Graph</a>';

function data(overrides: Record<string, unknown> = {}): string {
return JSON.stringify({ version: '1', commit: 'abc', records: [{ id: '0001' }, { id: '0002' }], issues: [], ...overrides });
}

function bundle(files: Record<string, string | null> = {}) {
const all: Record<string, string | null> = {
'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();
},
);
});
Loading
Loading