Skip to content
Open
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
50 changes: 49 additions & 1 deletion .github/workflows/checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,28 @@ jobs:
- name: Check types
run: npm run typecheck

- name: Run script tests
run: npm test

- name: Check for circular dependencies
run: npx madge --circular . --extensions ts,js,jsx,tsx

- name: Build with npm
run: npm run build

# Checking to ensure markdown link is not broken
# Checking to ensure markdown link is not broken.
#
# `blog/` is deliberately excluded here and handled by `markdown-link-check-new-blog-posts`
# below: published posts are point-in-time announcements whose external links rot on their own
# schedule, and checking the whole archive turns that rot into a failure on unrelated pull
# requests.
markdown-link-check-md:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: tcort/github-action-markdown-link-check@e047c5b37f24ab722bbef1a27b6fab7f96bc4068 # v1.1.3
with:
folder-path: 'docs'
file-extension: '.md'
use-quiet-mode: 'yes'
config-file: '.github/workflows/markdown.links.config.json'
Expand All @@ -65,6 +74,45 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: tcort/github-action-markdown-link-check@e047c5b37f24ab722bbef1a27b6fab7f96bc4068 # v1.1.3
with:
folder-path: 'docs'
file-extension: '.mdx'
use-quiet-mode: 'yes'
config-file: '.github/workflows/markdown.links.config.json'

# The root README, which `folder-path: docs` above no longer reaches.
markdown-link-check-root:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: tcort/github-action-markdown-link-check@e047c5b37f24ab722bbef1a27b6fab7f96bc4068 # v1.1.3
with:
max-depth: 1
file-extension: '.md'
use-quiet-mode: 'yes'
config-file: '.github/workflows/markdown.links.config.json'

# Blog posts the pull request ADDS still have to pass. Existing posts are left alone, so
# editing one does not put its historical links on trial.
markdown-link-check-new-blog-posts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# `newBlogPosts` diffs against the merge base, which needs both branches' history.
fetch-depth: 0

- name: Set up node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '22'

- name: Find newly added blog posts
id: new-posts
run: node scripts/new-blog-posts.mjs --base "origin/${{ github.base_ref }}" --github-output

- name: Check links in newly added blog posts
if: steps.new-posts.outputs.files != ''
run: |
npm ci
echo "${{ steps.new-posts.outputs.files }}" | tr ',' '\n' | xargs \
npx markdown-link-check --quiet --config .github/workflows/markdown.links.config.json
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"docker:build": "docker build . -t openfga-docs-ui",
"docker:run": "docker run -p ${PORT:-3000}:3000 openfga-docs-ui",
"typecheck": "tsc",
"test": "node --test scripts/*.test.mjs",
"lint": "eslint .",
"lint:fix": "npm run lint -- --fix",
"format:check": "prettier --check src/**",
Expand Down
93 changes: 93 additions & 0 deletions scripts/new-blog-posts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Selects the blog posts a pull request *adds*, so Markdown link checking can be limited to
* them.
*
* Historical posts are point-in-time announcements: their external links rot on their own
* schedule, and a full-tree link check turns that rot into a failure on whatever unrelated pull
* request happens to run next. New posts still have to pass, so the check is scoped to files the
* branch introduces rather than dropped.
*
* Prints one path per line (empty output when the branch adds no posts). With `--github-output`
* it also writes a comma-separated `files=` entry to `$GITHUB_OUTPUT`.
*
* Usage: node scripts/new-blog-posts.mjs [--base <ref>] [--github-output]
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';

export const BLOG_DIR = 'blog';

/** Docusaurus renders both as posts; everything else in `blog/` is configuration. */
export const POST_EXTENSIONS = ['.md', '.mdx'];

/**
* Keeps the blog posts out of a list of changed paths.
*
* @param {string[]} changedFiles - repository-relative paths, as `git diff --name-only` prints them
* @returns {string[]} the blog posts among them, in the order given
*/
export function selectBlogPosts(changedFiles) {
return changedFiles
.map((file) => file.trim())
.filter((file) => file.length > 0)
.filter((file) => path.posix.dirname(file) === BLOG_DIR)
.filter((file) => POST_EXTENSIONS.includes(path.posix.extname(file).toLowerCase()));
}

/**
* The `git diff` arguments that list the files a branch adds relative to `baseRef`.
*
* Three-dot: the comparison is against the merge base, so posts added to the base branch after
* this one forked are not mistaken for the branch's own.
*
* @param {string} baseRef
* @returns {string[]}
*/
export function addedFilesCommand(baseRef) {
return ['diff', '--name-only', '--diff-filter=A', `${baseRef}...HEAD`, '--', BLOG_DIR];
}

/**
* The blog posts this branch adds relative to `baseRef`.
*
* @param {string} baseRef
* @param {{ git?: (args: string[]) => string }} [options] - `git` is injectable for tests
* @returns {string[]}
*/
export function newBlogPosts(baseRef, { git = runGit } = {}) {
return selectBlogPosts(git(addedFilesCommand(baseRef)).split('\n'));
}

function runGit(args) {
return execFileSync('git', args, { encoding: 'utf8' });
}

/**
* @param {string[]} argv
* @returns {{ base: string, githubOutput: boolean }}
*/
export function parseArgs(argv) {
const base = argv.includes('--base') ? argv[argv.indexOf('--base') + 1] : undefined;
if (!base) {
throw new Error('--base <ref> is required');
}
return { base, githubOutput: argv.includes('--github-output') };
}

function main(argv) {
const { base, githubOutput } = parseArgs(argv);
const posts = newBlogPosts(base);

if (posts.length > 0) {
process.stdout.write(`${posts.join('\n')}\n`);
}

if (githubOutput && process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `files=${posts.join(',')}\n`);
}
}

if (process.argv[1] && import.meta.url === `file://${path.resolve(process.argv[1])}`) {
main(process.argv.slice(2));
}
121 changes: 121 additions & 0 deletions scripts/new-blog-posts.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { after, before, describe, it } from 'node:test';

import { addedFilesCommand, newBlogPosts, parseArgs, selectBlogPosts } from './new-blog-posts.mjs';

describe('selectBlogPosts', () => {
it('keeps blog posts', () => {
assert.deepEqual(selectBlogPosts(['blog/a-post.md', 'blog/another-post.mdx']), [
'blog/a-post.md',
'blog/another-post.mdx',
]);
});

it('drops everything outside blog/', () => {
assert.deepEqual(selectBlogPosts(['docs/content/overview.mdx', 'README.md', 'src/theme/Root.tsx']), []);
});

it('drops blog files that are not posts', () => {
// `authors.yml` is configuration, and nested assets are not rendered as posts.
assert.deepEqual(selectBlogPosts(['blog/authors.yml', 'blog/assets/diagram.png', 'blog/drafts/wip.md']), []);
});

it('ignores blank lines from git output', () => {
assert.deepEqual(selectBlogPosts(['blog/a-post.md', '', ' ']), ['blog/a-post.md']);
});
});

describe('addedFilesCommand', () => {
it('asks git only for additions, against the merge base, under blog/', () => {
const args = addedFilesCommand('origin/main');
assert.ok(args.includes('--diff-filter=A'), 'modifications must not be reported as additions');
assert.ok(args.includes('origin/main...HEAD'), 'three-dot keeps base-branch posts out of the branch diff');
assert.deepEqual(args.slice(-2), ['--', 'blog']);
});
});

describe('parseArgs', () => {
it('reads the base ref and the output flag', () => {
assert.deepEqual(parseArgs(['--base', 'origin/main', '--github-output']), {
base: 'origin/main',
githubOutput: true,
});
});

it('requires a base ref', () => {
assert.throws(() => parseArgs([]), /--base/);
});
});

/**
* The acceptance criteria are about what a *branch* produces, so these drive a real repository
* rather than a stubbed `git`.
*/
describe('newBlogPosts against a real repository', () => {
let repo;
const git = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8' });
const write = (file, body) => {
fs.mkdirSync(path.join(repo, path.dirname(file)), { recursive: true });
fs.writeFileSync(path.join(repo, file), body);
};
const commit = (message) => {
git('add', '-A');
git('commit', '-q', '-m', message);
};
const postsOnBranch = () =>
newBlogPosts('main', { git: (args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8' }) });

before(() => {
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'openfga-blog-'));
git('init', '-q', '-b', 'main');
git('config', 'user.email', 'test@example.com');
git('config', 'user.name', 'Test');
write('blog/existing-post.md', '# Existing\n[dead](https://example.invalid/gone)\n');
write('docs/content/overview.mdx', '# Docs\n');
commit('seed');
});

after(() => fs.rmSync(repo, { recursive: true, force: true }));

it('reports a post the branch adds', () => {
git('checkout', '-q', '-b', 'add-post');
write('blog/new-post.md', '# New\n');
commit('add a post');
assert.deepEqual(postsOnBranch(), ['blog/new-post.md']);
git('checkout', '-q', 'main');
});

it('reports nothing when the branch only edits an existing post', () => {
git('checkout', '-q', '-b', 'edit-post');
write('blog/existing-post.md', '# Existing\n[dead](https://example.invalid/gone)\nA new sentence.\n');
commit('edit a post');
assert.deepEqual(postsOnBranch(), []);
git('checkout', '-q', 'main');
});

it('reports nothing for a branch that touches no blog posts', () => {
git('checkout', '-q', '-b', 'docs-only');
write('docs/content/overview.mdx', '# Docs\nMore.\n');
commit('edit docs');
assert.deepEqual(postsOnBranch(), []);
git('checkout', '-q', 'main');
});

it('does not report a post added to main after the branch forked', () => {
git('checkout', '-q', '-b', 'stale-branch');
write('docs/content/overview.mdx', '# Docs\nBranch edit.\n');
commit('branch work');

git('checkout', '-q', 'main');
write('blog/landed-on-main.md', '# Landed\n');
commit('post on main');

git('checkout', '-q', 'stale-branch');
assert.deepEqual(postsOnBranch(), []);
git('checkout', '-q', 'main');
});
});