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
10 changes: 10 additions & 0 deletions .github/actions/bun-cache/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
name: Cache Bun downloads
description: Reuse package downloads while keeping frozen dependency installation
runs:
using: composite
steps:
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.bun/install/cache
key: bun-v1-${{ runner.os }}-${{ runner.arch }}-1.3.14-${{ hashFiles('**/bun.lock') }}
restore-keys: bun-v1-${{ runner.os }}-${{ runner.arch }}-1.3.14-
28 changes: 28 additions & 0 deletions .github/actions/ci-tools/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Prepare pinned CI tools
description: Restore or install verified tool binaries independently of application builds
inputs:
group:
description: worker, browser, or notices
required: true
runs:
using: composite
steps:
- id: info
shell: bash
env:
TOOL_GROUP: ${{ inputs.group }}
run: bun tools/ci/setup.ts info "$TOOL_GROUP"
- id: cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.info.outputs.path }}
key: ${{ steps.info.outputs.key }}
- shell: bash
env:
TOOL_GROUP: ${{ inputs.group }}
run: bun tools/ci/setup.ts install "$TOOL_GROUP"
- if: steps.cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.info.outputs.path }}
key: ${{ steps.info.outputs.key }}
121 changes: 105 additions & 16 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,33 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: verify-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
changes:
runs-on: ubuntu-24.04
outputs:
native: ${{ steps.select.outputs.native }}
tooling: ${{ steps.select.outputs.tooling }}
worker: ${{ steps.select.outputs.worker }}
configurable-chart: ${{ steps.select.outputs.configurable-chart }}
notices: ${{ steps.select.outputs.notices }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.14'
- run: bun test tools/ci
- id: select
run: bun tools/ci/changes.ts
usage-policy:
runs-on: ubuntu-24.04
steps:
Expand All @@ -14,14 +38,27 @@ jobs:
- run: python3 tools/verification/check_prohibited_usage.py --tree HEAD
- run: python3 tools/verification/test_prohibited_usage.py
notices:
needs: changes
if: needs.changes.outputs.notices == 'true'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.14'
- run: rustup toolchain install 1.96.0 --profile minimal
- run: cargo install cargo-about --version 0.9.2 --features cli --locked
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
cache-bin: false
workspaces: . -> target
- uses: ./.github/actions/ci-tools
with:
group: notices
- uses: ./.github/actions/bun-cache
- run: bun install --frozen-lockfile
working-directory: examples/configurable-chart
- run: bun examples/configurable-chart/node_modules/typescript/bin/tsc --project tools/notices/tsconfig.json
- run: bun examples/configurable-chart/node_modules/typescript/bin/tsc --project tools/ci/tsconfig.json
- run: cargo fetch --locked
- run: cargo fetch --locked --manifest-path examples/cloudflare-worker/Cargo.toml
- run: cargo fetch --locked --manifest-path examples/configurable-chart/wasm/Cargo.toml
Expand All @@ -31,51 +68,103 @@ jobs:
- run: test -s target/distribution/dataset-builder/ASTROLOGY_ENGINE_SOURCE.tar.gz && test -s target/distribution/dataset-builder/THIRD_PARTY_NOTICES.html && test -s target/distribution/dataset-builder/RUST_LIBRARY_NOTICES.html
- run: cargo package --locked --allow-dirty --list | python3 -c 'import sys; files=set(sys.stdin.read().splitlines()); assert {"LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.md", "notices/runtime.txt", "notices/RUST_LIBRARY_NOTICES.html"} <= files'
configurable-chart:
needs: changes
if: needs.changes.outputs.configurable-chart == 'true'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.14'
- run: rustup toolchain install 1.96.0 --profile minimal --component rustfmt --component clippy --target wasm32-unknown-unknown
- run: cargo install wasm-bindgen-cli --version 0.2.122 --locked
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
cache-bin: false
workspaces: examples/configurable-chart/wasm -> target
- uses: ./.github/actions/ci-tools
with:
group: browser
- uses: ./.github/actions/bun-cache
- run: cargo fmt --manifest-path examples/configurable-chart/wasm/Cargo.toml -- --check
- run: cargo clippy --locked --manifest-path examples/configurable-chart/wasm/Cargo.toml --target wasm32-unknown-unknown -- -D warnings
- run: bun install --frozen-lockfile
working-directory: examples/configurable-chart
- run: bun run build && bun run check
working-directory: examples/configurable-chart
- run: bun examples/configurable-chart/node_modules/typescript/bin/tsc --project tools/notices/tsconfig.json
- run: test -s examples/configurable-chart/build/ASTROLOGY_ENGINE_SOURCE.tar.gz && test -s examples/configurable-chart/build/THIRD_PARTY_NOTICES.html && test -s examples/configurable-chart/engine/THIRD_PARTY_NOTICES.txt && test -s examples/configurable-chart/build/RUST_LIBRARY_NOTICES.html
wasm-and-host:
native:
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-python@v5
with:
node-version: '22'
python-version: '3.13'
- run: rustup toolchain install 1.96.0 --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
cache-bin: false
workspaces: . -> target
- run: cargo fmt --all -- --check
- run: cargo test --locked --workspace
- run: cargo clippy --locked --workspace --all-targets -- -D warnings
- run: cargo fetch --locked --target wasm32-unknown-unknown
- run: python tools/verification/check_boundaries.py
- run: cargo package --locked --allow-dirty
tooling:
needs: changes
if: needs.changes.outputs.tooling == 'true'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: pip
cache-dependency-path: tools/jpl/requirements.lock
- run: python -m pip install --require-hashes -r tools/jpl/requirements.lock
- run: python tools/verification/test_compare.py
- run: python tools/verification/test_acquisition.py
- run: python tools/verification/test_generation.py
worker:
needs: changes
if: needs.changes.outputs.worker == 'true'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.14'
- run: rustup toolchain install 1.96.0 --profile minimal --component rustfmt --component clippy --target wasm32-unknown-unknown
- run: cargo fmt --all -- --check
- run: cargo test --locked --workspace
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
cache-bin: false
workspaces: examples/cloudflare-worker -> target
- uses: ./.github/actions/ci-tools
with:
group: worker
- uses: ./.github/actions/bun-cache
- run: cargo fmt --manifest-path examples/cloudflare-worker/Cargo.toml -- --check
- run: cargo clippy --locked --workspace --all-targets -- -D warnings
- run: cargo clippy --locked --manifest-path examples/cloudflare-worker/Cargo.toml --target wasm32-unknown-unknown --features verification -- -D warnings
- run: cargo install worker-build --version 0.8.3 --locked
- run: bun install --frozen-lockfile
working-directory: examples/cloudflare-worker
- run: bun run build:verification
working-directory: examples/cloudflare-worker
- run: test -s examples/cloudflare-worker/build/worker/ASTROLOGY_ENGINE_SOURCE.tar.gz && test -s examples/cloudflare-worker/build/worker/THIRD_PARTY_NOTICES.txt && test -s examples/cloudflare-worker/build/RUST_LIBRARY_NOTICES.html
- run: node examples/cloudflare-worker/verify.mjs
- run: python tools/verification/test_compare.py
- run: python -m pip install --require-hashes -r tools/jpl/requirements.lock
- run: python tools/verification/test_acquisition.py
- run: python tools/verification/test_generation.py
- run: python tools/verification/check_boundaries.py
- run: cargo package --locked --allow-dirty
verify:
if: always()
needs: [changes, usage-policy, native, tooling, notices, configurable-chart, worker]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.14'
- run: bun tools/ci/gate.ts
env:
CI_NEEDS: ${{ toJSON(needs) }}
3 changes: 2 additions & 1 deletion tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ astronomical accuracy.
See the [regular verification workflow](../.github/workflows/verify.yml) for the
repository's formatting, linting, packaging, and local Wasm checks, and the
[full-data workflow](../.github/workflows/full-data.yml) for explicit regeneration
and reference-dataset parity runs.
and reference-dataset parity runs. The [CI guide](ci/README.md) describes PR
selection, independent jobs, tool caching and download failure handling.

## Commit checks

Expand Down
71 changes: 71 additions & 0 deletions tools/ci/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Regular CI

The Verify workflow runs once for each PR update, on pushes to `main`, and on
manual dispatch. Feature branches without a PR can use manual dispatch. New PR
updates cancel superseded runs. Main and manual runs always select every job.

PR selection uses the merge base of the PR base/head commits. Renames include
both paths, and unavailable comparisons select everything. The `changes` job
runs the CI helper tests and records its selections in the run summary:

| Changes | Checks |
| --- | --- |
| Prose documentation | Usage policy, helper tests, final gate |
| Worker example source | Worker, notices |
| Browser example source | Browser, notices |
| Native builder or Python preparation source | Native, preparation tooling, notices |
| Runtime, shared verification, CI, licensing, notice inputs, unknown files | Everything |

Notice input fingerprints from both base and head take precedence over prose
and adapter-specific rules. Executable documentation is code. Native tests,
preparation tooling, and Worker integration run independently. The final
`verify` job accepts intentionally skipped jobs and rejects selected jobs that
fail, are cancelled, or unexpectedly skip. No deployment or full-data generation
occurs in regular CI.

## Caches and tool downloads

Rust dependency caches are separate for each job and workspace, keyed by the
Rust toolchain, manifests/lockfiles and compiler environment. They exclude
workspace build products. Bun/pip caches hold package downloads, not installed
application output. Dependency installation remains locked and hash-checked.

The `ci-tools` composite action caches tools separately from source compilation.
Its key includes platform, tool versions, download checksums, setup code and the
Rust toolchain. Successful setup saves its cache immediately so later failures
do not discard it. Every restored executable must report the expected version.

`worker-build` and `cargo-about` are installed with exact versions and `--locked`
on a cold cache. Their versions come from the existing notice tool constants.
`downloads.json` records the official Linux x64 helper archives and SHA-256
hashes: wasm-bindgen 0.2.122, Binaryen 129, and esbuild 0.28.0. These match the
Worker build tool's resolver. Archives are verified before extraction; cached
binaries are version checked. Update URLs, checksums, expected version output,
and corresponding notice material together when intentionally upgrading tools.

Transport errors, HTTP 408/429 and 5xx responses get at most three attempts,
with 2/5-second backoff and a 60-second timeout per attempt. Other HTTP errors,
checksum mismatches, wrong versions, compilation errors and test failures are
not retried. Cargo uses its bounded network retry setting during installation.
Tool setup exports the supported Worker binary overrides and the browser's
`WASM_BINDGEN` override, so builds use the validated binaries. Wasm optimization
stays enabled. Local developer build setup is unchanged.

To invalidate a broken tool cache, delete its entry in GitHub Actions caches;
never bypass version or checksum checks. No final Wasm/site/native distribution,
notice bundle, source archive, or dataset is restored as a CI artifact cache.

## Local checks

```sh
bun test tools/ci
bun tools/ci/changes.ts # without a PR event, selects every job
bun tools/ci/setup.ts info worker # prints the platform/tool cache identity
bun examples/configurable-chart/node_modules/typescript/bin/tsc --project tools/ci/tsconfig.json
actionlint
```

TypeScript checking uses the browser example's locked development dependencies.
The notices job installs them and checks both notice and CI tooling, independent
of whether browser integration was selected. The full-data workflow retains its
explicit invocation and existing setup.
60 changes: 60 additions & 0 deletions tools/ci/changes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Jack Asher
// SPDX-License-Identifier: MPL-2.0

import { test, expect } from 'bun:test';
import { mkdtempSync, writeFileSync, renameSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { allJobs, changedPaths, determineSelection, jobs, selectJobs } from './changes.ts';
import { parseSelection, verifyResults, type Result } from './gate.ts';
const chosen = (paths: string[], inputs: string[] = []) => jobs.filter(job => selectJobs(paths, inputs)[job]);
test('prose skips expensive checks; executable docs and unknown files run all', () => {
expect(chosen(['README.md', 'docs/architecture.md'])).toEqual([]);
for (const path of ['docs/demo.ts', 'unknown.config', 'tests/fixtures/regression.txt', 'src/new.rs', '.github/workflows/verify.yml', 'LICENSE', 'NOTICE']) {
expect(chosen([path])).toEqual([...jobs]);
}
});
test('gate rejects absent or malformed selection outputs', () => {
expect(() => parseSelection({})).toThrow('selection');
const outputs = Object.fromEntries(jobs.map(job => [job, 'true']));
expect(parseSelection(outputs)).toEqual(allJobs());
expect(() => parseSelection({ ...outputs, worker: 'tru' })).toThrow('worker');
});
test('adapter and preparation changes select their consumers', () => {
expect(chosen(['examples/cloudflare-worker/src/lib.rs'])).toEqual(['worker', 'notices']);
expect(chosen(['examples/configurable-chart/main.ts'])).toEqual(['configurable-chart', 'notices']);
expect(chosen(['tools/jpl/horizons.py', 'tools/dataset-builder/src/main.rs'])).toEqual(['native', 'tooling', 'notices']);
});
test('notice inputs override prose and narrow adapter selection', () => {
expect(chosen(['tools/notices/README.md'])).toEqual([...jobs]);
expect(chosen(['THIRD_PARTY_NOTICES.md'], ['THIRD_PARTY_NOTICES.md'])).toEqual([...jobs]);
expect(chosen(['examples/cloudflare-worker/Cargo.toml'], ['examples/cloudflare-worker/Cargo.toml'])).toEqual([...jobs]);
});
test('main/manual and unreliable PR comparisons run everything', () => {
const fail = () => { throw new Error('unavailable base'); };
expect(determineSelection('push', fail)).toEqual(allJobs());
expect(determineSelection('workflow_dispatch', fail)).toEqual(allJobs());
expect(determineSelection('pull_request', fail)).toEqual(allJobs());
});
test('renames and deletions include former paths and use the merge base', () => {
const dir = mkdtempSync(join(tmpdir(), 'ci-diff-'));
const git = (args: string[]) => execFileSync('git', args, { cwd: dir, encoding: 'utf8' });
try {
git(['init', '-q']); git(['config', 'user.email', 'test@example.invalid']); git(['config', 'user.name', 'CI test']);
writeFileSync(join(dir, 'old.rs'), 'source'); git(['add', '.']); git(['commit', '-qm', 'base']);
const base = git(['rev-parse', 'HEAD']).trim();
renameSync(join(dir, 'old.rs'), join(dir, 'README.md')); git(['add', '-A']); git(['commit', '-qm', 'rename']);
const head = git(['rev-parse', 'HEAD']).trim();
expect(changedPaths(base, head, git).sort()).toEqual(['README.md', 'old.rs']);
expect(chosen(changedPaths(base, head, git))).toEqual([...jobs]);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('gate accepts intentional skips, rejects selected skips and every failure/cancellation', () => {
const selection = selectJobs(['README.md'], []);
const results: Record<string, Result> = { changes: 'success', 'usage-policy': 'success', ...Object.fromEntries(jobs.map(job => [job, 'skipped' as const])) };
verifyResults(selection, results);
expect(() => verifyResults(allJobs(), results)).toThrow();
for (const result of ['failure', 'cancelled'] as const) expect(() => verifyResults(selection, { ...results, worker: result })).toThrow();
expect(() => verifyResults(selection, { ...results, changes: 'failure' })).toThrow();
});
Loading
Loading