mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-25 10:33:21 +00:00
Add reusable consumer CI for library commit previews
This commit is contained in:
1 parent
ce0524d49f
commit
f475ea1c63
4 files changed
+342
No files matched your search
@@ -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
|
||||
@@ -42,6 +42,7 @@ on:
|
||||
- '.github/workflows/cli-p2p-tests.yml'
|
||||
- '.github/workflows/cli-docker-validation.yml'
|
||||
- '.github/workflows/cli-docker.yml'
|
||||
- '.github/workflows/library-preview.yml'
|
||||
- '.github/workflows/unit-ci.yml'
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
@@ -80,6 +81,7 @@ on:
|
||||
- '.github/workflows/cli-p2p-tests.yml'
|
||||
- '.github/workflows/cli-docker-validation.yml'
|
||||
- '.github/workflows/cli-docker.yml'
|
||||
- '.github/workflows/library-preview.yml'
|
||||
- '.github/workflows/unit-ci.yml'
|
||||
|
||||
permissions:
|
||||
@@ -150,6 +152,9 @@ jobs:
|
||||
node-version: '24.x'
|
||||
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
|
||||
run: npx --yes npm@10.9.4 ci --ignore-scripts --no-audit --no-fund
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
#### 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
|
||||
|
||||
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\./);
|
||||
});
|
||||
Reference in new issue
Block a user