Skip to content

Merge pull request #28 from link-foundation/issue-27-05348a151c4d #110

Merge pull request #28 from link-foundation/issue-27-05348a151c4d

Merge pull request #28 from link-foundation/issue-27-05348a151c4d #110

Workflow file for this run

name: Checks and release
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
# Manual release support - consolidated here to work with npm trusted publishing
# npm only allows ONE workflow file as trusted publisher, so all publishing
# must go through this workflow (release.yml)
workflow_dispatch:
inputs:
release_mode:
description: 'Manual release mode'
required: true
type: choice
default: 'instant'
options:
- instant
- changeset-pr
bump_type:
description: 'Manual release type'
required: true
type: choice
options:
- patch
- minor
- major
description:
description: 'Manual release description (optional)'
required: false
type: string
# Concurrency: Only one workflow run per branch at a time
# - For main branch (releases): cancel older runs to prevent blocking newer releases
# When multiple commits are pushed quickly, we want the latest to release, not wait
# - For PR branches: queue runs to avoid cancelling checks on force-pushes
# See: docs/case-studies/issue-25/DETAILED-COMPARISON.md for context
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
jobs:
# === DETECT CHANGES - determines which jobs should run ===
detect-changes:
name: Detect Changes
runs-on: ubuntu-latest
# Typical run: ~5s. Cap at 5min so a hung detection step
# surfaces quickly instead of stalling the whole pipeline.
timeout-minutes: 5
if: github.event_name != 'workflow_dispatch'
outputs:
mjs-changed: ${{ steps.changes.outputs.mjs-changed }}
js-changed: ${{ steps.changes.outputs.js-changed }}
package-changed: ${{ steps.changes.outputs.package-changed }}
docs-changed: ${{ steps.changes.outputs.docs-changed }}
workflow-changed: ${{ steps.changes.outputs.workflow-changed }}
any-code-changed: ${{ steps.changes.outputs.any-code-changed }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Detect changes
id: changes
run: node js/scripts/detect-code-changes.mjs
# === FAST CHECKS - run before slow tests for fastest feedback ===
# See: hive-mind CI/CD best practices principle #5 (fast-fail job ordering)
# Syntax check all .mjs files with node --check (~7s)
test-compilation:
name: Test Compilation
runs-on: ubuntu-latest
# Typical run: ~4s. Tight cap fails fast on accidental infinite loops.
timeout-minutes: 5
needs: [detect-changes]
if: |
github.event_name == 'push' ||
needs.detect-changes.outputs.mjs-changed == 'true' ||
needs.detect-changes.outputs.js-changed == 'true'
steps:
- uses: actions/checkout@v6
- name: Check .mjs syntax
run: bash js/scripts/check-mjs-syntax.sh
# Enforce 1500-line limit on .mjs files and release.yml
check-file-line-limits:
name: Check File Line Limits
runs-on: ubuntu-latest
# Typical run: ~3s. Just walks the tree counting lines.
timeout-minutes: 5
needs: [detect-changes]
if: |
github.event_name == 'push' ||
needs.detect-changes.outputs.mjs-changed == 'true' ||
needs.detect-changes.outputs.js-changed == 'true' ||
needs.detect-changes.outputs.workflow-changed == 'true'
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Simulate fresh merge with base branch (PR only)
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: bash js/scripts/simulate-fresh-merge.sh
- name: Check file line limits
run: bash js/scripts/check-file-line-limits.sh
# === VERSION CHANGE CHECK ===
# Prohibit manual version changes in package.json - versions should only be changed by CI/CD
version-check:
name: Check for Manual Version Changes
runs-on: ubuntu-latest
# Typical run: ~6s. Read-only diff inspection.
timeout-minutes: 5
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check for version changes in package.json
env:
GITHUB_HEAD_REF: ${{ github.head_ref }}
GITHUB_BASE_REF: ${{ github.base_ref }}
run: node js/scripts/check-version.mjs
# === CHANGESET CHECK - only runs on PRs with code changes ===
# Docs-only PRs (./docs folder, markdown files) don't require changesets
changeset-check:
name: Check for Changesets
runs-on: ubuntu-latest
# Typical run: ~13s including npm install. 10min covers cold cache.
timeout-minutes: 10
needs: [detect-changes]
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true'
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
- name: Install dependencies
run: npm install
- name: Check for changesets
env:
# Pass PR context to the validation script
GITHUB_BASE_REF: ${{ github.base_ref }}
GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Skip changeset check for automated version PRs
if [[ "${{ github.head_ref }}" == "changeset-release/"* ]]; then
echo "Skipping changeset check for automated release PR"
exit 0
fi
# Run changeset validation script
# This validates that exactly ONE changeset was ADDED by this PR
# Pre-existing changesets from other merged PRs are ignored
node js/scripts/validate-changeset.mjs
# === LINT AND FORMAT CHECK ===
# Lint runs independently of changeset-check - it's a fast check that should always run
# See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed
# IMPORTANT: ESLint includes max-lines rule (1500 lines) to ensure files stay maintainable
# See docs/case-studies/issue-23 for why fresh merge simulation is critical
lint:
name: Lint and Format Check
runs-on: ubuntu-latest
# Typical run: ~23s including npm install + ESLint + Prettier + jscpd
# + secretlint. 10min cap protects against a hung lint plugin.
timeout-minutes: 10
needs: [detect-changes]
if: |
github.event_name == 'push' ||
needs.detect-changes.outputs.mjs-changed == 'true' ||
needs.detect-changes.outputs.js-changed == 'true' ||
needs.detect-changes.outputs.docs-changed == 'true' ||
needs.detect-changes.outputs.package-changed == 'true' ||
needs.detect-changes.outputs.workflow-changed == 'true'
steps:
- uses: actions/checkout@v6
with:
# For PRs, fetch enough history to merge with base branch
fetch-depth: 0
- name: Simulate fresh merge with base branch (PR only)
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: bash js/scripts/simulate-fresh-merge.sh
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
- name: Install dependencies
run: npm install
- name: Run ESLint
run: npm run lint
- name: Check formatting
run: npm run format:check
- name: Check code duplication
run: npm run check:duplication
- name: Check for secrets
run: npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint "**/*"
# Test matrix: 3 runtimes (Node.js, Bun, Deno) x 3 OS (Ubuntu, macOS, Windows)
# IMPORTANT: Tests must validate the ACTUAL merge result, not a stale merge preview.
# See docs/case-studies/issue-23 for why this is critical.
# Fast-fail: slow test matrix only runs after fast checks pass (hive-mind principle #5)
test:
name: Test (${{ matrix.runtime }} on ${{ matrix.os }})
runs-on: ${{ matrix.os }}
# Typical run: 13–55s (deno on Windows can spike to ~2min on cold runners).
# 10min cap surfaces a hung test or flaky network well before the
# 6h GitHub default — the user wants flaky tests to fail fast.
timeout-minutes: 10
needs:
[
detect-changes,
changeset-check,
test-compilation,
lint,
check-file-line-limits,
]
# Use !cancelled() instead of always() so cancellation propagates correctly (hive-mind issue #1278)
# Run if: push event, OR changeset-check succeeded, OR changeset-check was skipped (docs-only PR)
# AND all fast checks passed (or were skipped for irrelevant changes)
if: |
!cancelled() &&
(github.event_name == 'push' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') &&
(needs.test-compilation.result == 'success' || needs.test-compilation.result == 'skipped') &&
(needs.lint.result == 'success' || needs.lint.result == 'skipped') &&
(needs.check-file-line-limits.result == 'success' || needs.check-file-line-limits.result == 'skipped')
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runtime: [node, bun, deno]
steps:
- uses: actions/checkout@v6
with:
# For PRs, fetch enough history to merge with base branch
fetch-depth: 0
- name: Simulate fresh merge with base branch (PR only)
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
shell: bash
run: bash js/scripts/simulate-fresh-merge.sh
- name: Setup Node.js
if: matrix.runtime == 'node'
uses: actions/setup-node@v6
with:
node-version: '24.x'
- name: Install dependencies (Node.js)
if: matrix.runtime == 'node'
run: npm install
- name: Run tests (Node.js)
if: matrix.runtime == 'node'
run: npm test
- name: Setup Bun
if: matrix.runtime == 'bun'
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies (Bun)
if: matrix.runtime == 'bun'
run: bun install
- name: Run tests (Bun)
if: matrix.runtime == 'bun'
# --timeout caps an individual test at 30s. Default is 5s but a few
# WebRTC / WebSocket scenarios run slower on cold runners; 30s leaves
# headroom while still failing fast on a hung promise.
run: bun test --timeout 30000
- name: Setup Deno
if: matrix.runtime == 'deno'
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Run tests (Deno)
if: matrix.runtime == 'deno'
run: deno test --allow-read --allow-write --allow-env --allow-net --allow-sys js/tests
# === API DOCUMENTATION BUILD ===
# Build JS API docs (`npm run docs:api`) and Rust crate docs
# (`cargo doc --manifest-path rust/Cargo.toml --no-deps`) on every push and PR. This catches doc-comment
# errors before release and produces artefacts the `release` job attaches
# to GitHub Releases (R-H3).
docs-build:
name: Build API Docs
runs-on: ubuntu-latest
# Typical run: ~25s. cargo doc dominates and is well-bounded.
timeout-minutes: 15
needs: [detect-changes]
if: |
github.event_name == 'push' ||
needs.detect-changes.outputs.mjs-changed == 'true' ||
needs.detect-changes.outputs.js-changed == 'true' ||
needs.detect-changes.outputs.docs-changed == 'true'
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
- name: Install dependencies
run: npm install
- name: Build JS API docs
run: npm run docs:api
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Build Rust crate docs
run: cargo doc --manifest-path rust/Cargo.toml --no-deps --workspace
- name: Upload JS API docs artifact
uses: actions/upload-artifact@v4
with:
name: docs-api-js
path: docs/api/
if-no-files-found: error
retention-days: 30
- name: Upload Rust crate docs artifact
uses: actions/upload-artifact@v4
with:
name: docs-api-rust
path: rust/target/doc/
if-no-files-found: error
retention-days: 30
# === DOCUMENTATION VALIDATION ===
# Validate documentation files when docs change (hive-mind principle #12)
validate-docs:
name: Validate Documentation
runs-on: ubuntu-latest
# Typical run: <5s. Pure shell file walk.
timeout-minutes: 5
needs: [detect-changes]
if: |
github.event_name == 'push' ||
needs.detect-changes.outputs.docs-changed == 'true'
steps:
- uses: actions/checkout@v6
- name: Check documentation file sizes
run: |
LIMIT=2500
FAILURES=()
echo "Checking that documentation files are under ${LIMIT} lines..."
while IFS= read -r -d '' file; do
line_count=$(wc -l < "$file")
if [ "$line_count" -gt "$LIMIT" ]; then
echo "ERROR: $file has $line_count lines (limit: ${LIMIT})"
echo "::error file=$file::Documentation file has $line_count lines (limit: ${LIMIT})"
FAILURES+=("$file")
fi
done < <(find docs -name "*.md" -type f -print0 2>/dev/null)
if [ "${#FAILURES[@]}" -gt 0 ]; then
echo "The following docs exceed the ${LIMIT} line limit:"
printf ' %s\n' "${FAILURES[@]}"
exit 1
else
echo "All documentation files are within the ${LIMIT} line limit."
fi
- name: Check required documentation files exist
run: |
REQUIRED_FILES=(
"docs/BEST-PRACTICES.md"
"docs/CONTRIBUTING.md"
"docs/ROADMAP.md"
"docs/REQUIREMENTS.md"
"README.md"
"CHANGELOG.md"
)
MISSING=()
for file in "${REQUIRED_FILES[@]}"; do
if [ ! -f "$file" ]; then
echo "ERROR: Required documentation file missing: $file"
MISSING+=("$file")
else
echo "Found: $file"
fi
done
if [ "${#MISSING[@]}" -gt 0 ]; then
echo ""
echo "Missing required documentation files:"
printf ' %s\n' "${MISSING[@]}"
exit 1
else
echo "All required documentation files present."
fi
# Release - only runs on main after tests pass (for push events)
release:
name: Release
# Cap publish work (changeset merge, version commit, npm publish,
# GitHub release, doc upload). Typical run is well under 10min;
# 30min covers npm registry retries on a bad day.
timeout-minutes: 30
needs: [lint, test, docs-build]
# Use !cancelled() instead of always() so cancellation propagates correctly (hive-mind issue #1278)
# This is needed because lint/test jobs have a transitive dependency on changeset-check
if: |
!cancelled() &&
github.ref == 'refs/heads/main' &&
github.event_name == 'push' &&
needs.lint.result == 'success' &&
needs.test.result == 'success' &&
(needs.docs-build.result == 'success' || needs.docs-build.result == 'skipped')
runs-on: ubuntu-latest
# Permissions required for npm OIDC trusted publishing
permissions:
contents: write
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm install
- name: Update npm for OIDC trusted publishing
run: node js/scripts/setup-npm.mjs
- name: Check for changesets
id: check_changesets
run: node js/scripts/check-changesets.mjs
- name: Check if release is needed
id: check_release
env:
HAS_CHANGESETS: ${{ steps.check_changesets.outputs.has_changesets }}
run: node js/scripts/check-release-needed.mjs
- name: Merge multiple changesets
if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1
run: |
echo "Multiple changesets detected, merging..."
node js/scripts/merge-changesets.mjs
- name: Version packages and commit to main
if: steps.check_changesets.outputs.has_changesets == 'true'
id: version
run: node js/scripts/version-and-commit.mjs --mode changeset
- name: Publish to npm
# Run if version was committed, if a previous attempt already committed (for re-runs),
# or if check-release-needed detected an unpublished version (self-healing, issue #36)
if: >-
steps.version.outputs.version_committed == 'true' ||
steps.version.outputs.already_released == 'true' ||
(steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true')
id: publish
run: node js/scripts/publish-to-npm.mjs --should-pull
- name: Create GitHub Release
if: steps.publish.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node js/scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}"
- name: Format GitHub release notes
if: steps.publish.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node js/scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}"
- name: Build JS API docs for release
if: steps.publish.outputs.published == 'true'
run: npm run docs:api
- name: Setup Rust toolchain for release docs
if: steps.publish.outputs.published == 'true'
uses: dtolnay/rust-toolchain@stable
- name: Build Rust crate docs for release
if: steps.publish.outputs.published == 'true'
run: cargo doc --manifest-path rust/Cargo.toml --no-deps --workspace
- name: Attach API docs to GitHub Release
if: steps.publish.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash js/scripts/attach-api-docs.sh "v${{ steps.publish.outputs.published_version }}"
# Manual Instant Release - triggered via workflow_dispatch with instant mode
# This job is in release.yml because npm trusted publishing
# only allows one workflow file to be registered as a trusted publisher
instant-release:
name: Instant Release
if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant'
runs-on: ubuntu-latest
# Same envelope as the automated release path.
timeout-minutes: 30
# Permissions required for npm OIDC trusted publishing
permissions:
contents: write
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm install
- name: Update npm for OIDC trusted publishing
run: node js/scripts/setup-npm.mjs
- name: Version packages and commit to main
id: version
run: node js/scripts/version-and-commit.mjs --mode instant --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}"
- name: Publish to npm
# Run if version was committed OR if a previous attempt already committed (for re-runs)
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
id: publish
run: node js/scripts/publish-to-npm.mjs
- name: Create GitHub Release
if: steps.publish.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node js/scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}"
- name: Format GitHub release notes
if: steps.publish.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node js/scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}"
- name: Build JS API docs for release
if: steps.publish.outputs.published == 'true'
run: npm run docs:api
- name: Setup Rust toolchain for release docs
if: steps.publish.outputs.published == 'true'
uses: dtolnay/rust-toolchain@stable
- name: Build Rust crate docs for release
if: steps.publish.outputs.published == 'true'
run: cargo doc --manifest-path rust/Cargo.toml --no-deps --workspace
- name: Attach API docs to GitHub Release
if: steps.publish.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash js/scripts/attach-api-docs.sh "v${{ steps.publish.outputs.published_version }}"
# Manual Changeset PR - creates a pull request with the changeset for review
changeset-pr:
name: Create Changeset PR
if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr'
runs-on: ubuntu-latest
# PR creation only — install + format + open PR. 10min is generous.
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
- name: Install dependencies
run: npm install
- name: Create changeset file
run: node js/scripts/create-manual-changeset.mjs --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}"
- name: Format changeset with Prettier
run: |
# Run Prettier on the changeset file to ensure it matches project style
npx prettier --write ".changeset/*.md" || true
echo "Formatted changeset files"
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release'
branch: changeset-manual-release-${{ github.run_id }}
delete-branch: true
title: 'chore: manual ${{ github.event.inputs.bump_type }} release'
body: |
## Manual Release Request
This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release.
### Release Details
- **Type:** ${{ github.event.inputs.bump_type }}
- **Description:** ${{ github.event.inputs.description || 'Manual release' }}
- **Triggered by:** @${{ github.actor }}
### Next Steps
1. Review the changeset in this PR
2. Merge this PR to main
3. The automated release workflow will create a version PR
4. Merge the version PR to publish to npm and create a GitHub release