Repository navigation
Order release publication channels #196
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| # Cancel superseded runs on the same ref. | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| jobs: | |
| version: | |
| name: Release version invariant | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - run: python3 tools/check-version.py | |
| # --------------------------------------------------------------------------- | |
| rust: | |
| name: Rust workspace | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install Rust toolchain (per rust-toolchain.toml) | |
| run: | | |
| rustup toolchain install | |
| rustup component add rustfmt clippy | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: cargo fmt --check | |
| run: cargo fmt --all -- --check | |
| - name: cargo clippy | |
| run: cargo clippy --all-targets --all-features -- -D warnings | |
| - name: cargo check | |
| run: cargo check --all-targets --all-features --locked | |
| - name: cargo test | |
| run: cargo test --all-features --locked | |
| - name: cargo build --release (smoke) | |
| run: cargo build --release --locked | |
| - name: coderef binary --version | |
| run: ./target/release/coderef --version | |
| - name: coderef config show <example> | |
| run: ./target/release/coderef config show examples/minimal.coderef.jsonc > /dev/null | |
| - name: coderef list — scan-self smoke | |
| shell: bash | |
| run: | | |
| # Build a tiny temp workspace + config and verify the scanner | |
| # discovers the planted reference. | |
| tmp=$(mktemp -d) | |
| cat > "$tmp/.coderef.jsonc" <<'EOF' | |
| { "patterns": { "todo": { | |
| "regex": "TODO\\(@(?<user>\\w+)\\)", | |
| "target": "https://x/${user}", | |
| "scope": { "commentsOnly": true } | |
| } } } | |
| EOF | |
| printf 'fn main() { // TODO(@ci) — proves the scanner runs\n}\n' > "$tmp/a.rs" | |
| out=$(./target/release/coderef list "$tmp") | |
| echo "$out" | |
| echo "$out" | grep -q "TODO(@ci)" || { echo "scanner did not find planted reference"; exit 1; } | |
| rm -rf "$tmp" | |
| - name: coderef check — local-path verify smoke | |
| shell: bash | |
| run: | | |
| # Plant a local docref to a real file and one to a missing | |
| # file. Confirm the binary exits 1 (one broken), reports both | |
| # correctly, and that --report json emits valid JSON. | |
| tmp=$(mktemp -d) | |
| cat > "$tmp/.coderef.jsonc" <<'EOF' | |
| { "patterns": { "docref": { | |
| "regex": "DOCREF\\((?<path>/?[^)\\s#]+)\\)", | |
| "kind": "local", | |
| "target": "${path}", | |
| "scope": { "commentsOnly": true } | |
| } } } | |
| EOF | |
| mkdir -p "$tmp/docs" | |
| echo "real" > "$tmp/docs/exists.md" | |
| echo "// DOCREF(/docs/exists.md)" > "$tmp/a.rs" | |
| echo "// DOCREF(/docs/missing.md)" > "$tmp/b.rs" | |
| set +e | |
| ./target/release/coderef check "$tmp" | |
| ec=$? | |
| set -e | |
| if [ "$ec" -ne 1 ]; then | |
| echo "expected exit 1 (one broken ref), got $ec" | |
| exit 1 | |
| fi | |
| # And the JSON form must parse + report broken=1. | |
| json=$(./target/release/coderef check --report json "$tmp" || true) | |
| echo "$json" | python3 -c 'import json,sys; r=json.load(sys.stdin); assert r["broken"]==1 and r["ok"]==1, r' | |
| rm -rf "$tmp" | |
| - name: coderef doctor — static checks smoke | |
| shell: bash | |
| run: | | |
| # Plant a config that triggers every v0.1 check category. | |
| tmp=$(mktemp -d) | |
| cat > "$tmp/.coderef.jsonc" <<'EOF' | |
| { | |
| "patterns": { | |
| "ok": { "regex": "OK\\(@(?<u>\\w+)\\)", "target": "x/${u}" }, | |
| "noTarget": { "regex": "X" }, | |
| "unusedCap": { "regex": "(?<extra>X)", "target": "x" }, | |
| "badRegex": { "regex": "(?<u>X", "target": "x" }, | |
| "futureNs": { "regex": "X", "target": "x/${git:branch}" }, | |
| "configUnknown": { "regex": "X", "target": "${config:missingVar}" } | |
| } | |
| } | |
| EOF | |
| set +e | |
| out=$(./target/release/coderef doctor --no-scan "$tmp") | |
| ec=$? | |
| set -e | |
| echo "$out" | |
| if [ "$ec" -ne 1 ]; then | |
| echo "expected exit 1 (errors present), got $ec" | |
| exit 1 | |
| fi | |
| # Must produce at least one diagnostic of each expected kind. | |
| for check in pattern.targetMissing pattern.regexInvalid pattern.captureUnused \ | |
| pattern.variableConfigUnknown pattern.variableNamespaceFuture; do | |
| echo "$out" | grep -q "$check" || { echo "missing diagnostic: $check"; exit 1; } | |
| done | |
| json=$(./target/release/coderef doctor --no-scan --report json "$tmp" || true) | |
| echo "$json" | python3 -c 'import json,sys; r=json.load(sys.stdin); assert r["errors"] >= 3, r' | |
| rm -rf "$tmp" | |
| - name: Self-dogfood — coderef doctor + check against this repo | |
| shell: bash | |
| run: | | |
| # The project's own .coderef.jsonc must pass static checks + | |
| # every TODO(@user) / DOCREF(/path) reference it contains must | |
| # resolve. This is what the .pre-commit-config.yaml's | |
| # coderef-doctor / coderef-check hooks enforce locally. | |
| ./target/release/coderef doctor --no-scan . | |
| ./target/release/coderef check . | |
| - name: Regenerate JSON Schema from Rust types | |
| run: cargo run --bin coderef-gen-schema --locked | |
| - name: Verify committed schema matches generated schema | |
| run: | | |
| if ! git diff --exit-code -- schema/coderef.schema.json; then | |
| echo "" | |
| echo "schema/coderef.schema.json is out of date. Re-run:" | |
| echo " cargo run --bin coderef-gen-schema" | |
| echo "and commit the result." | |
| exit 1 | |
| fi | |
| # --------------------------------------------------------------------------- | |
| wasm: | |
| name: WASM build + Node smoke | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install Rust toolchain (per rust-toolchain.toml) | |
| run: | | |
| rustup toolchain install | |
| rustup target add wasm32-unknown-unknown | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| # Separate cache from the host-triple build above; same lockfile. | |
| key: wasm-${{ hashFiles('Cargo.lock') }} | |
| - name: Install wasm-pack | |
| run: | | |
| curl -fsSL https://rustwasm.github.io/wasm-pack/installer/init.sh | sh | |
| wasm-pack --version | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| - name: wasm-pack build (nodejs target) | |
| run: wasm-pack build --release --target nodejs --out-dir pkg crates/coderef-core-wasm | |
| - name: Node smoke — import + version + scan_buffer + doctor_static | |
| shell: bash | |
| run: | | |
| # Tiny end-to-end script: load the wasm-pack output, run each | |
| # exported function on representative input, and assert the | |
| # results match what the host-triple tests already proved. | |
| # The script lives in $GITHUB_WORKSPACE so the relative | |
| # require() to the wasm-pack output dir resolves correctly. | |
| cat > "$GITHUB_WORKSPACE/wasm-smoke.cjs" <<'EOF' | |
| const m = require('./crates/coderef-core-wasm/pkg/coderef_core_wasm.js'); | |
| // 1. version() | |
| const v = m.version(); | |
| if (typeof v !== 'string' || v.length === 0) { | |
| console.error('version() returned bad value:', v); | |
| process.exit(1); | |
| } | |
| console.log('version:', v); | |
| // 2. parse_config() — exercises every field the extension | |
| // might encounter (description, scope, severity) so a | |
| // Pattern-struct change without a wasm-pack rebuild is | |
| // caught here rather than at extension activation. | |
| const cfgJsonc = `{ | |
| "patterns": { | |
| "todo": { | |
| "description": "GitHub @user mention inside a TODO marker", | |
| "regex": "TODO\\\\(@(?<user>\\\\w+)\\\\)", | |
| "target": "https://x/${'$'}{user}", | |
| "scope": { "commentsOnly": true }, | |
| "severity": { "pattern.unused": "warning" } | |
| } | |
| } | |
| }`; | |
| const cfg = m.parse_config(cfgJsonc); | |
| const cfgJson = JSON.stringify(cfg); | |
| if (!cfgJson.includes('"todo"')) { | |
| console.error('parse_config missing pattern:', cfgJson); | |
| process.exit(1); | |
| } | |
| if (!cfgJson.includes('GitHub @user mention')) { | |
| console.error('parse_config dropped description (WASM may be stale):', cfgJson); | |
| process.exit(1); | |
| } | |
| // 3. scan_buffer() on a Rust-ish file | |
| const content = '// TODO(@alice) — fix this'; | |
| const refs = m.scan_buffer(content, cfgJson, 'rs', 'test.rs'); | |
| if (!Array.isArray(refs) || refs.length !== 1) { | |
| console.error('scan_buffer: expected 1 ref, got', refs); | |
| process.exit(1); | |
| } | |
| const r = refs[0]; | |
| if (r.pattern_id !== 'todo' || r.captures.user !== 'alice' || r.target !== 'https://x/alice') { | |
| console.error('scan_buffer: bad shape', r); | |
| process.exit(1); | |
| } | |
| console.log('scan_buffer: ok (target=' + r.target + ')'); | |
| // 4. doctor_static() on a config with an error | |
| const broken = JSON.stringify({ patterns: { x: { regex: 'X' } } }); // no target | |
| const report = m.doctor_static(broken); | |
| if (report.errors < 1) { | |
| console.error('doctor_static: expected at least 1 error, got', report); | |
| process.exit(1); | |
| } | |
| console.log('doctor_static: ok (errors=' + report.errors + ')'); | |
| // 5. explain_text() — drives the extension's | |
| // coderef.explainReference command. The shape returned | |
| // must include `matches` (array) and | |
| // `non_matching_pattern_ids` (array). | |
| const exp = m.explain_text(cfgJson, 'TODO(@bob)'); | |
| if (!Array.isArray(exp.matches) || exp.matches.length !== 1) { | |
| console.error('explain_text: expected 1 match, got', exp); | |
| process.exit(1); | |
| } | |
| if (exp.matches[0].target !== 'https://x/bob') { | |
| console.error('explain_text: bad resolved target', exp.matches[0]); | |
| process.exit(1); | |
| } | |
| console.log('explain_text: ok (target=' + exp.matches[0].target + ')'); | |
| EOF | |
| (cd "$GITHUB_WORKSPACE" && node ./wasm-smoke.cjs) | |
| rm -f "$GITHUB_WORKSPACE/wasm-smoke.cjs" | |
| - name: WASM package size budget (< 2 MB) | |
| run: | | |
| # v0.1 budget: 2 MB. Current size hovers ~1.6 MB even with | |
| # wasm-opt --enable-bulk-memory; the bulk is fancy-regex (its | |
| # own regex engine) + schemars (drag from coderef-core's | |
| # JsonSchema derives, only used by gen-schema in practice) + | |
| # serde_json. Two follow-up paths to shrink: feature-gate | |
| # `schemars` so the WASM build excludes it; consider regex-lite | |
| # for the WASM target if the JS host doesn't need lookarounds. | |
| # Either is a polish PR, not a v0.1 blocker. | |
| wasm=$(ls crates/coderef-core-wasm/pkg/*.wasm | head -1) | |
| size=$(wc -c < "$wasm") | |
| echo "wasm size: $size bytes" | |
| if [ "$size" -gt 2097152 ]; then | |
| echo "WASM binary exceeded 2 MB budget" | |
| exit 1 | |
| fi | |
| # --------------------------------------------------------------------------- | |
| extension: | |
| name: VSCode extension (TS + VSIX) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| # Rust toolchain + wasm-pack are needed for `npm run build` / | |
| # `vsce package`, which run scripts/bundle-wasm.cjs to produce the | |
| # in-VSIX WASM artefacts (extension/out/wasm/*). | |
| - name: Install Rust toolchain | |
| run: | | |
| rustup toolchain install | |
| rustup target add wasm32-unknown-unknown | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| key: extension-${{ hashFiles('Cargo.lock') }} | |
| - name: Install wasm-pack | |
| run: curl -fsSL https://rustwasm.github.io/wasm-pack/installer/init.sh | sh | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| - name: Install dependencies (extension) | |
| working-directory: extension | |
| run: npm install --no-audit --no-fund | |
| - name: tsc --noEmit | |
| working-directory: extension | |
| run: npx tsc --noEmit -p ./ | |
| - name: eslint | |
| working-directory: extension | |
| run: npx eslint src || true # v0.0.0 has no eslint config yet | |
| - name: Unit tests (node --test, pure-function paths) | |
| working-directory: extension | |
| run: npm test | |
| - name: Runtime tests (@vscode/test-electron in a real VSCode) | |
| working-directory: extension | |
| # @vscode/test-cli launches a real VSCode binary; on the | |
| # ubuntu-latest runner that needs Xvfb because GitHub's image | |
| # is headless. | |
| run: xvfb-run -a npm run test-runtime | |
| - name: Build (compile + bundle-wasm) and package VSIX | |
| working-directory: extension | |
| run: npm run package | |
| - name: Verify VSIX contains the bundled wasm-pack output | |
| working-directory: extension | |
| shell: bash | |
| run: | | |
| # The bundle-wasm script must have copied the wasm-pack output | |
| # into out/wasm/ before vsce zipped the package; the VSIX is | |
| # just a zip so we can inspect it. | |
| vsix=$(ls *.vsix | head -1) | |
| echo "inspecting $vsix" | |
| unzip -l "$vsix" | tee /tmp/vsix-contents.txt | |
| for need in \ | |
| "extension/out/extension.js" \ | |
| "extension/out/wasm/coderef_core_wasm.js" \ | |
| "extension/out/wasm/coderef_core_wasm_bg.wasm"; do | |
| grep -q "$need" /tmp/vsix-contents.txt || { | |
| echo "VSIX is missing $need" | |
| exit 1 | |
| } | |
| done | |
| # Source TS, scripts, and tests must NOT ship. | |
| for forbid in \ | |
| "extension/src/" \ | |
| "extension/scripts/" \ | |
| "extension/out/test/" \ | |
| "wasmEngine.ts"; do | |
| if grep -q "$forbid" /tmp/vsix-contents.txt; then | |
| echo "VSIX contains $forbid but shouldn't" | |
| exit 1 | |
| fi | |
| done | |
| # --------------------------------------------------------------------------- | |
| npm-wrapper: | |
| name: npm wrapper smoke | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install Rust toolchain (cargo for the source-build fallback) | |
| run: rustup toolchain install | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| key: npm-wrapper-${{ hashFiles('Cargo.lock') }} | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| - name: cargo build --release --bin coderef (sibling binary for install.js to discover) | |
| run: cargo build --release --locked --bin coderef | |
| - name: npm install (runs postinstall → sibling-binary path) | |
| working-directory: npm/coderef | |
| run: npm install --no-audit --no-fund | |
| - name: Wrapper forwards args + exit codes | |
| working-directory: npm/coderef | |
| shell: bash | |
| run: | | |
| # Verify the wrapper found + installed the binary and that | |
| # invoking it through the wrapper produces the same output | |
| # as invoking it directly. | |
| want=$(./bin/coderef --version) | |
| have=$(node ./bin/coderef.js --version) | |
| echo "wrapper: $have" | |
| test "$want" = "$have" || { echo "version mismatch: $want vs $have"; exit 1; } | |
| # Exit code propagation: unknown subcommand → exit 2. | |
| set +e | |
| node ./bin/coderef.js bogus-subcommand | |
| code=$? | |
| set -e | |
| test $code -eq 2 || { echo "expected exit 2 for unknown subcommand, got $code"; exit 1; } | |
| # --------------------------------------------------------------------------- | |
| schema: | |
| name: JSON Schema validation | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: Install jsonschema | |
| run: python -m pip install --upgrade pip jsonschema | |
| - name: Validate the schema parses as valid JSON | |
| run: python -c "import json; json.load(open('schema/coderef.schema.json'))" | |
| - name: Validate example configs against the schema | |
| run: | | |
| python3 tools/validate-config.py \ | |
| schema/coderef.schema.json \ | |
| examples/minimal.coderef.jsonc | |
| # --------------------------------------------------------------------------- | |
| docs: | |
| name: Docs hygiene (table alignment + cross-refs) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: Run table aligner (must be idempotent) | |
| run: | | |
| python3 tools/align-md-tables.py \ | |
| DESIGN.md README.md AGENTS.md CLAUDE.md \ | |
| schema/README.md npm/coderef/README.md extension/README.md docs/README.md | |
| if ! git diff --exit-code -- '*.md' '**/*.md'; then | |
| echo "" | |
| echo "Tables in the above files are not aligned. Re-run:" | |
| echo " python3 tools/align-md-tables.py DESIGN.md README.md ..." | |
| exit 1 | |
| fi | |
| - name: Verify §-cross-references in DESIGN.md | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| headers=$(grep -oE '^#+ [0-9]+(\.[0-9]+)*' DESIGN.md | sed 's/^#* //') | |
| refs=$(grep -oE '§[0-9]+(\.[0-9]+)*' DESIGN.md | sed 's/^§//' | sort -u) | |
| missing=0 | |
| for r in $refs; do | |
| if ! echo "$headers" | grep -qxE "$r"; then | |
| echo "MISSING TARGET: §$r" | |
| missing=1 | |
| fi | |
| done | |
| exit $missing | |
| - name: Code-fence balance check | |
| run: | | |
| n=$(grep -c '^```' DESIGN.md) | |
| if [ $((n % 2)) -ne 0 ]; then | |
| echo "Unbalanced code fences in DESIGN.md: $n" | |
| exit 1 | |
| fi | |
| # Aggregating gate for branch protection: mark only `done` as the | |
| # required check. Two parts: | |
| # | |
| # 1. Self-check: parse this file with `yq` and fail if any job | |
| # other than `done` itself is absent from `needs:` below. | |
| # Stops a newly added job from silently escaping the gate. | |
| # 2. Failure aggregation: one-line `contains(needs.*.result, ...)` | |
| # guard. GitHub's Actions UI already shows the per-job | |
| # results, so an elaborate result-table step doesn't add | |
| # anything reviewers can't see one click away. | |
| done: | |
| needs: [version, rust, wasm, extension, npm-wrapper, schema, docs] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Ensure every job is wired into this gate | |
| env: | |
| NEEDS_JSON: ${{ toJSON(needs) }} | |
| run: | | |
| # Parse the workflow with a real YAML reader (yq) and fail if | |
| # any declared job (other than this gate) is absent from | |
| # `needs` above, so a newly added job cannot silently escape | |
| # the required gate. | |
| declared="$(yq '.jobs | keys | .[]' .github/workflows/ci.yml | | |
| grep -vx done | sort)" | |
| wired="$(jq -r 'keys[]' <<<"${NEEDS_JSON}" | sort)" | |
| missing="$(comm -23 <(printf '%s\n' "${declared}") <(printf '%s\n' "${wired}"))" | |
| if [[ -n "${missing}" ]]; then | |
| echo "Jobs declared in the workflow but missing from done.needs:" | |
| while read -r job; do | |
| echo " - ${job}" | |
| echo "::error title=Gate is missing a job::${job} not in done.needs" | |
| done <<<"${missing}" | |
| exit 1 | |
| fi | |
| echo "done covers all $(grep -c . <<<"${declared}") workflow jobs." | |
| - name: Fail if any dependency did not succeed | |
| if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') | |
| run: exit 1 |