diff --git a/.github/workflows/spec-drift-watch.yml b/.github/workflows/spec-drift-watch.yml new file mode 100644 index 0000000..6c65348 --- /dev/null +++ b/.github/workflows/spec-drift-watch.yml @@ -0,0 +1,112 @@ +name: spec drift watch + +# Once a week, checks every standard's spec against the state last read for +# it (the pins in spec-pins.json, generated from `spec_sources` in +# content/standards). For each standard whose spec changed, it opens or +# refreshes one issue that links what changed; it closes the issue once the +# pins are moved. It also publishes a small status file to the +# `spec-drift-status` branch, which the standard pages read to show "The spec +# changed after this check." +# +# The check itself is scripts/spec-drift.mjs, shared byte for byte with Ora's +# watcher (eralabs-ai/ora). It needs no npm install. Upstream content never +# reaches a shell: the script runs git and gh with argument arrays, and only +# hashes, revision names and digests reach the issues. + +on: + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: + +# Two jobs, so the job that reads upstream repositories and pages holds no +# write token: `check` runs read-only and hands its report to `report`, +# which alone can write issues and the status branch. +permissions: + contents: read + +concurrency: + group: spec-drift-watch + cancel-in-progress: false + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + outcome: ${{ steps.check.outcome }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Compare every spec with its pins + id: check + continue-on-error: true + run: node scripts/spec-drift.mjs check spec-pins.json --out "$RUNNER_TEMP/report.json" + + - uses: actions/upload-artifact@v4 + with: + name: spec-drift-report + path: ${{ runner.temp }}/report.json + retention-days: 7 + + report: + needs: check + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # push the status file to the spec-drift-status branch only + issues: write + env: + GH_TOKEN: ${{ github.token }} + STATUS_BRANCH: spec-drift-status + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - uses: actions/download-artifact@v4 + with: + name: spec-drift-report + path: ${{ runner.temp }} + + - name: Read the last published status + run: | + # The branch is missing until the first run publishes it. + if git ls-remote --exit-code --heads origin "$STATUS_BRANCH" >/dev/null; then + git fetch --depth 1 origin "+refs/heads/$STATUS_BRANCH:refs/remotes/origin/$STATUS_BRANCH" + git show "origin/$STATUS_BRANCH:spec-drift.json" > "$RUNNER_TEMP/status-in.json" + fi + + - name: Open, refresh or close the drift issues + run: | + node scripts/spec-drift.mjs issues "$RUNNER_TEMP/report.json" --repo "$GITHUB_REPOSITORY" \ + --status-in "$RUNNER_TEMP/status-in.json" --status-out "$RUNNER_TEMP/status-out.json" + + - name: Publish the status for the site + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + dir="$RUNNER_TEMP/status-branch" + if git rev-parse --verify --quiet "origin/$STATUS_BRANCH" >/dev/null; then + git worktree add --detach "$dir" "origin/$STATUS_BRANCH" + else + git worktree add --orphan -b "$STATUS_BRANCH" "$dir" + fi + cp "$RUNNER_TEMP/status-out.json" "$dir/spec-drift.json" + git -C "$dir" add spec-drift.json + if git -C "$dir" diff --cached --quiet; then + echo "status unchanged" + exit 0 + fi + git -C "$dir" commit --quiet -m "chore: update spec drift status" + git -C "$dir" push origin "HEAD:refs/heads/$STATUS_BRANCH" + + - name: Fail when a spec could not be read + if: needs.check.outputs.outcome == 'failure' + run: | + echo "At least one spec source could not be read; see the check step. Its issue was left as it was." + exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c1209c..0b501f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ By taking part you agree to follow the [code of conduct](CODE_OF_CONDUCT.md). | You want to | Do this | | --- | --- | | Fix a wrong sentence or a stale status | Press **Edit on GitHub** at the foot of the standard's page, change the line, link your source, open a pull request. | -| Re-check a page | Check every claim against its sources, fix what changed, update `last_verified`. | +| Re-check a page | Check every claim against its sources, fix what changed, update `last_verified`. If the spec itself changed, move its pins too (see [Spec sources](#spec-sources)). | | Add a standard | Copy [`content/standards/_template.md`](content/standards/_template.md) to `content/standards/.md` and fill it in. | | Report a problem without fixing it | [Report an error](https://github.com/ora/standards/issues/new?template=correction.yml). | | Suggest a standard without writing it | [Suggest a standard](https://github.com/ora/standards/issues/new?template=new-standard.yml). | @@ -44,6 +44,18 @@ A partner list is not adoption, and a transaction count is not commerce. If a nu - Put the spec first in `links`. - Change `last_verified` only when you have rechecked **every** claim on the page. +## Spec sources + +Every standard lists where its spec lives in `spec_sources`, with a `pin`: the exact state of the spec that was read (a Git SHA, a release tag, a draft revision, or a digest of a page's text). Once a week a check ([`scripts/spec-drift.mjs`](scripts/spec-drift.mjs), run by [`spec-drift-watch.yml`](.github/workflows/spec-drift-watch.yml)) compares each spec with its pins. When one changed, it opens an issue that links what changed, and the standard's page says "The spec changed after this check" until the pins move. The template lists the kinds of source. + +When you handle one of those issues: + +1. Read what changed and recheck the page against it. +2. Set each moved `pin` to the live value the issue lists, even when nothing on the page needed to change: moving a pin is the record that someone read the change. +3. Run `npm run pins`. It regenerates [`spec-pins.json`](spec-pins.json), which the check reads, and the tests fail until it matches the content files. + +To see the live state yourself, run `node scripts/spec-drift.mjs check spec-pins.json --only `. It needs Node and Git, no install. [Ora](https://ora.ai) reads `spec-pins.json` too and compares it with the pins behind its own checks, so tell us in the pull request if a change looks like it should change how sites are scored. + ## House style - Plain words and short sentences. Explain a term the first time it appears. @@ -75,7 +87,7 @@ npm test npm run build ``` -If you added a standard or changed a name or a `phrase`, run `npm run readme` too: it regenerates the table of standards in the README, and the tests fail until it matches. +If you added a standard or changed a name or a `phrase`, run `npm run readme` too: it regenerates the table of standards in the README, and the tests fail until it matches. If you changed `spec_sources`, run `npm run pins` for the same reason. ## License of contributions diff --git a/app/standards/[id]/page.tsx b/app/standards/[id]/page.tsx index cbd8b0b..07d6740 100644 --- a/app/standards/[id]/page.tsx +++ b/app/standards/[id]/page.tsx @@ -6,6 +6,7 @@ import { StandardTile } from "@/components/standard-tile"; import { StatusBadge } from "@/components/status-badge"; import { content, editHref, formatDate } from "@/lib/content"; import { renderInline, renderMarkdown } from "@/lib/markdown"; +import { getSpecDrift } from "@/lib/spec-drift"; import { daysSince, getJob, getStandard, getStandards } from "@/lib/standards"; import "../standard.css"; @@ -13,6 +14,9 @@ type Props = { params: Promise<{ id: string }> }; // Every standard is a file, so every page is made at build time. export const dynamicParams = false; +// Made again at most hourly (SPEC_DRIFT_REVALIDATE), to pick up the spec +// drift status and keep the "out of date" mark current between deploys. +export const revalidate = 3600; export function generateStaticParams() { return getStandards().map((standard) => ({ id: standard.id })); @@ -42,6 +46,7 @@ export default async function StandardPage({ params }: Props) { return target ? [{ ...r, standard: target }] : []; }); const stale = daysSince(standard.last_verified) > content.staleAfterDays; + const drift = await getSpecDrift(standard.id); const file = `content/standards/${standard.id}.md`; return ( @@ -117,10 +122,19 @@ export default async function StandardPage({ params }: Props) {