Add reusable consumer CI for library commit previews

This commit is contained in:
vorotamoroz committed 2026-10-23 01:37:02 +00:00
1 parent ce0524d49f
commit f475ea1c63
4 files changed
+342

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
name: Consumer CI with library previews
on:
workflow_call:
inputs:
commonlib_sha:
description: Full source SHA of the livesync-commonlib commit to preview, or blank to use the locked registry package.
required: false
type: string
default: ''
kit_sha:
description: Full source SHA of the fancy-kit commit to preview, or blank to use the locked registry packages.
required: false
type: string
default: ''
consumer_sha:
description: Full Self-hosted LiveSync commit SHA to validate. Blank is allowed only when called from vrtmrz/obsidian-livesync.
required: false
type: string
default: ''
workflow_dispatch:
inputs:
commonlib_sha:
description: Full source SHA of the livesync-commonlib commit to preview, or blank to use the locked registry package.
required: false
type: string
default: ''
kit_sha:
description: Full source SHA of the fancy-kit commit to preview, or blank to use the locked registry packages.
required: false
type: string
default: ''
consumer_sha:
description: Full Self-hosted LiveSync commit SHA to validate. Blank is allowed only when called from vrtmrz/obsidian-livesync.
required: false
type: string
default: ''
permissions:
actions: read
contents: read
jobs:
consumer-ci:
name: Validate Self-hosted LiveSync
runs-on: ubuntu-latest
timeout-minutes: 60
env:
NODE_OPTIONS: --max-old-space-size=3072
steps:
- name: Validate preview SHAs and resolve the consumer commit
id: inputs
shell: bash
env:
INPUT_COMMONLIB_SHA: ${{ inputs.commonlib_sha }}
INPUT_KIT_SHA: ${{ inputs.kit_sha }}
INPUT_CONSUMER_SHA: ${{ inputs.consumer_sha }}
CALLER_REPOSITORY: ${{ github.repository }}
CALLER_SHA: ${{ github.sha }}
run: |
node --input-type=module <<'NODE'
import { appendFileSync } from 'node:fs';
const fullSha = /^[0-9a-f]{40}$/;
function getSha(name) {
const value = process.env[name] ?? '';
if (value !== '' && !fullSha.test(value)) {
throw new Error(`${name} must be blank or a full 40-character lowercase SHA`);
}
return value;
}
const commonlibSha = getSha('INPUT_COMMONLIB_SHA');
const kitSha = getSha('INPUT_KIT_SHA');
let consumerSha = getSha('INPUT_CONSUMER_SHA');
if (consumerSha === '') {
if (process.env.CALLER_REPOSITORY !== 'vrtmrz/obsidian-livesync') {
throw new Error('consumer_sha is required when the caller is not vrtmrz/obsidian-livesync');
}
consumerSha = getSha('CALLER_SHA');
}
if (!fullSha.test(consumerSha)) {
throw new Error('consumer_sha must resolve to a full 40-character lowercase SHA');
}
appendFileSync(process.env.GITHUB_OUTPUT, `commonlib_sha=${commonlibSha}\n`);
appendFileSync(process.env.GITHUB_OUTPUT, `kit_sha=${kitSha}\n`);
appendFileSync(process.env.GITHUB_OUTPUT, `consumer_sha=${consumerSha}\n`);
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Validated consumer commit: \`${consumerSha}\`\n`);
NODE
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: '24.x'
- name: Check out Self-hosted LiveSync
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: vrtmrz/obsidian-livesync
ref: ${{ steps.inputs.outputs.consumer_sha }}
token: ${{ github.token }}
persist-credentials: false
- name: Install library commit previews
uses: vrtmrz/fancy-kit/.github/actions/install-library-preview@b428f23887e4c0ad33515beb625ef490ec38945b
with:
commonlib-sha: ${{ steps.inputs.outputs.commonlib_sha }}
kit-sha: ${{ steps.inputs.outputs.kit_sha }}
github-token: ${{ github.token }}
- name: Set up Deno
uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2
with:
deno-version: v2.x
- name: Install dependencies
run: npm ci
- name: Check TypeScript types
run: npm run tsc-check
- name: Run unit tests
run: npm run test:unit -- --maxWorkers=1
- name: Build plug-in
run: npm run build
- name: Build CLI workspace
run: npm run build --workspace self-hosted-livesync-cli
- name: Build WebApp workspace
run: npm run build --workspace livesync-webapp
- name: Build WebPeer workspace
run: npm run build --workspace webpeer
- name: Run CLI setup-put-cat contract test
run: deno task --cwd src/apps/cli/testdeno test:setup-put-cat
+5
View File
@@ -42,6 +42,7 @@ on:
- '.github/workflows/cli-p2p-tests.yml' - '.github/workflows/cli-p2p-tests.yml'
- '.github/workflows/cli-docker-validation.yml' - '.github/workflows/cli-docker-validation.yml'
- '.github/workflows/cli-docker.yml' - '.github/workflows/cli-docker.yml'
- '.github/workflows/library-preview.yml'
- '.github/workflows/unit-ci.yml' - '.github/workflows/unit-ci.yml'
pull_request: pull_request:
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
@@ -80,6 +81,7 @@ on:
- '.github/workflows/cli-p2p-tests.yml' - '.github/workflows/cli-p2p-tests.yml'
- '.github/workflows/cli-docker-validation.yml' - '.github/workflows/cli-docker-validation.yml'
- '.github/workflows/cli-docker.yml' - '.github/workflows/cli-docker.yml'
- '.github/workflows/library-preview.yml'
- '.github/workflows/unit-ci.yml' - '.github/workflows/unit-ci.yml'
permissions: permissions:
@@ -150,6 +152,9 @@ jobs:
node-version: '24.x' node-version: '24.x'
cache: 'npm' cache: 'npm'
- name: Test library preview workflow inputs
run: node --test --test-isolation=none test/workflows/libraryPreviewInputs.test.mjs
- name: Verify clean installation with npm 10 - name: Verify clean installation with npm 10
run: npx --yes npm@10.9.4 ci --ignore-scripts --no-audit --no-fund run: npx --yes npm@10.9.4 ci --ignore-scripts --no-audit --no-fund
+10
View File
@@ -118,6 +118,16 @@ Shared synchronisation code is compiled and typed by the `@vrtmrz/livesync-commo
Changes spanning both repositories must first produce a packed Commonlib artefact which passes its standalone package checks. Install that exact artefact in LiveSync, then run the LiveSync type checks, unit tests, application builds, CLI E2E, and any focused real-Obsidian E2E required by the changed boundary. Replace the temporary artefact reference with the reviewed immutable package version before release. Changes spanning both repositories must first produce a packed Commonlib artefact which passes its standalone package checks. Install that exact artefact in LiveSync, then run the LiveSync type checks, unit tests, application builds, CLI E2E, and any focused real-Obsidian E2E required by the changed boundary. Replace the temporary artefact reference with the reviewed immutable package version before release.
#### Testing library commits in CI
The [`library-preview.yml`](.github/workflows/library-preview.yml) workflow can be dispatched in this repository or called from another workflow. Both entry points accept the same optional string inputs:
- `commonlib_sha`: a full, lowercase, 40-character source commit SHA from `vrtmrz/livesync-commonlib`, or blank to keep the Commonlib package version already recorded in the lockfile;
- `kit_sha`: a full, lowercase, 40-character source commit SHA from `vrtmrz/fancy-kit`, or blank to keep the Kit packages already recorded in the lockfile; and
- `consumer_sha`: a full, lowercase, 40-character Self-hosted LiveSync commit SHA to validate. When blank, the workflow uses the caller's `github.sha` only if the caller repository is `vrtmrz/obsidian-livesync`; callers from other repositories must supply it.
The workflow rejects non-empty values which are not full lowercase SHAs before checking out the selected consumer commit. Reusable workflow callers must grant `actions: read` and `contents: read`. It installs requested package previews from their matching successful CI runs, then runs `npm ci`, the LiveSync type check and unit tests, the plug-in and application builds, and the CLI `setup-put-cat` contract test. A blank library SHA leaves that dependency on its normal lockfile package. The Fancy Kit install action is pinned independently from `kit_sha`.
## Architecture ## Architecture
The [Project glossary](docs/glossary.md#developer-and-design-terms) defines the The [Project glossary](docs/glossary.md#developer-and-design-terms) defines the
@@ -0,0 +1,185 @@
import assert from 'node:assert/strict';
import { appendFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url));
const workflowPath = join(repositoryRoot, '.github/workflows/library-preview.yml');
const workflow = readFileSync(workflowPath, 'utf8');
const fullSha = 'a'.repeat(40);
function readValidationProgram() {
const lines = workflow.split(/\r?\n/);
const stepIndex = lines.findIndex((line) => line.trim() === '- name: Validate preview SHAs and resolve the consumer commit');
assert.notEqual(stepIndex, -1, 'workflow must contain the SHA validation step');
const runIndex = lines.findIndex((line, index) => index > stepIndex && line === ' run: |');
assert.notEqual(runIndex, -1, 'SHA validation step must use a literal run block');
const scriptLines = [];
for (let index = runIndex + 1; index < lines.length; index += 1) {
const line = lines[index];
if (line.trim() === '') {
scriptLines.push('');
continue;
}
if (!line.startsWith(' ')) break;
scriptLines.push(line.slice(10));
}
const shellScript = scriptLines.join('\n').trimEnd();
const heredoc = shellScript.match(/^node --input-type=module <<'NODE'\n([\s\S]*)\nNODE$/);
assert.ok(heredoc, 'SHA validation must pass its literal Node program through a quoted heredoc');
return heredoc[1];
}
const validationProgram = readValidationProgram();
function runValidation(overrides = {}) {
const directory = mkdtempSync(join(tmpdir(), 'library-preview-inputs-'));
const outputPath = join(directory, 'output');
const summaryPath = join(directory, 'summary');
const env = {
...process.env,
INPUT_COMMONLIB_SHA: '',
INPUT_KIT_SHA: '',
INPUT_CONSUMER_SHA: '',
CALLER_REPOSITORY: 'vrtmrz/obsidian-livesync',
CALLER_SHA: fullSha,
GITHUB_OUTPUT: outputPath,
GITHUB_STEP_SUMMARY: summaryPath,
...overrides,
};
appendFileSync(outputPath, '');
appendFileSync(summaryPath, '');
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', validationProgram], {
cwd: repositoryRoot,
env,
encoding: 'utf8',
});
const outputs = readFileSync(outputPath, 'utf8')
.trim()
.split(/\r?\n/)
.filter(Boolean)
.map((line) => line.split(/=(.*)/s).slice(0, 2));
const summary = readFileSync(summaryPath, 'utf8');
rmSync(directory, { recursive: true, force: true });
return {
...result,
outputs: Object.fromEntries(outputs),
summary,
};
}
test('blank library SHAs resolve the caller commit for the LiveSync repository', () => {
const result = runValidation();
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.outputs, {
commonlib_sha: '',
kit_sha: '',
consumer_sha: fullSha,
});
assert.match(result.summary, new RegExp(fullSha));
});
test('a reusable workflow caller from another repository must pass consumer_sha', () => {
const result = runValidation({ CALLER_REPOSITORY: 'vrtmrz/livesync-commonlib' });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /consumer_sha is required/);
});
test('an explicit consumer SHA is accepted for an external caller', () => {
const consumerSha = 'b'.repeat(40);
const result = runValidation({
CALLER_REPOSITORY: 'vrtmrz/livesync-commonlib',
INPUT_COMMONLIB_SHA: fullSha,
INPUT_KIT_SHA: 'c'.repeat(40),
INPUT_CONSUMER_SHA: consumerSha,
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.outputs, {
commonlib_sha: fullSha,
kit_sha: 'c'.repeat(40),
consumer_sha: consumerSha,
});
});
test('a supplied consumer SHA takes precedence over the caller commit', () => {
const consumerSha = 'b'.repeat(40);
const result = runValidation({ INPUT_CONSUMER_SHA: consumerSha });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.outputs.consumer_sha, consumerSha);
});
test('all supplied SHAs reject anything other than a full lowercase hexadecimal SHA', async (t) => {
const invalidValues = ['main', 'ABCDEF0123456789abcdef0123456789abcdef01', 'a'.repeat(39), `${fullSha}\n`];
for (const [inputName, displayName] of [
['INPUT_COMMONLIB_SHA', 'commonlib_sha'],
['INPUT_KIT_SHA', 'kit_sha'],
['INPUT_CONSUMER_SHA', 'consumer_sha'],
]) {
await t.test(displayName, async (inputTest) => {
for (const invalidValue of invalidValues) {
await inputTest.test(JSON.stringify(invalidValue), () => {
const result = runValidation({ [inputName]: invalidValue });
assert.notEqual(result.status, 0);
assert.match(result.stderr, new RegExp(`${inputName} must be blank or a full 40-character lowercase SHA`));
});
}
});
}
});
test('workflow exposes the specified inputs, read-only permissions, and independently pinned actions', () => {
assert.match(workflow, /^ workflow_call:\n inputs:/m);
assert.match(workflow, /^ workflow_dispatch:\n inputs:/m);
for (const input of ['commonlib_sha', 'kit_sha', 'consumer_sha']) {
assert.match(workflow, new RegExp(`^ ${input}:`, 'm'));
}
assert.match(workflow, /^permissions:\n actions: read\n contents: read$/m);
assert.doesNotMatch(workflow, /^\s+(actions|contents): write$/m);
assert.doesNotMatch(workflow, /pull_request_target|secrets:\s*inherit/);
for (const actionRef of [
'actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0',
'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e',
'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed',
]) {
assert.ok(workflow.includes(actionRef), `workflow must pin ${actionRef}`);
}
assert.match(workflow, /^ uses: vrtmrz\/fancy-kit\/\.github\/actions\/install-library-preview@[0-9a-f]{40}(?:\s+#.*)?$/m);
assert.match(workflow, /repository: vrtmrz\/obsidian-livesync\n\s+ref: \$\{\{ steps\.inputs\.outputs\.consumer_sha \}\}/);
assert.match(workflow, /commonlib-sha: \$\{\{ steps\.inputs\.outputs\.commonlib_sha \}\}/);
assert.match(workflow, /kit-sha: \$\{\{ steps\.inputs\.outputs\.kit_sha \}\}/);
assert.match(workflow, /github-token: \$\{\{ github\.token \}\}/);
});
test('consumer workflow runs all required checks and keeps input values out of shell source', () => {
for (const command of [
'npm ci',
'npm run tsc-check',
'npm run test:unit -- --maxWorkers=1',
'npm run build',
'npm run build --workspace self-hosted-livesync-cli',
'npm run build --workspace livesync-webapp',
'npm run build --workspace webpeer',
'deno task --cwd src/apps/cli/testdeno test:setup-put-cat',
]) {
assert.ok(workflow.includes(command), `workflow must run ${command}`);
}
assert.match(workflow, /INPUT_COMMONLIB_SHA: \$\{\{ inputs\.commonlib_sha \}\}/);
assert.match(workflow, /INPUT_KIT_SHA: \$\{\{ inputs\.kit_sha \}\}/);
assert.match(workflow, /INPUT_CONSUMER_SHA: \$\{\{ inputs\.consumer_sha \}\}/);
assert.doesNotMatch(validationProgram, /\$\{\{\s*inputs\./);
});