fix: add git config and content verification to JS CI/CD publish #20
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
| # IMPORTANT: npm trusted publishing (OIDC) requires this workflow filename to match | |
| # the "Workflow" field configured in npm package settings at npmjs.com. | |
| # Currently configured as: js.yml (for link-foundation/lino-arguments) | |
| # If you rename this file, update the npm trusted publisher config accordingly. | |
| # See: docs/case-studies/issue-31/case-study.md | |
| name: JavaScript CI/CD | |
| on: | |
| push: | |
| branches: | |
| - main | |
| paths: | |
| - 'js/**' | |
| - '.github/workflows/js.yml' | |
| pull_request: | |
| types: [opened, synchronize, reopened] | |
| paths: | |
| - 'js/**' | |
| - '.github/workflows/js.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: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| defaults: | |
| run: | |
| working-directory: js | |
| jobs: | |
| # === DETECT CHANGES - determines which jobs should run === | |
| detect-changes: | |
| name: Detect Changes | |
| runs-on: ubuntu-latest | |
| if: github.event_name != 'workflow_dispatch' | |
| outputs: | |
| js-changed: ${{ steps.changes.outputs.js-changed }} | |
| package-changed: ${{ steps.changes.outputs.package-changed }} | |
| mjs-changed: ${{ steps.changes.outputs.mjs-changed }} | |
| docs-changed: ${{ steps.changes.outputs.docs-changed }} | |
| js-workflow-changed: ${{ steps.changes.outputs.js-workflow-changed }} | |
| js-code-changed: ${{ steps.changes.outputs.js-code-changed }} | |
| js-package-changed: ${{ steps.changes.outputs.js-package-changed }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.x' | |
| - name: Detect changes | |
| id: changes | |
| working-directory: . | |
| env: | |
| GITHUB_EVENT_NAME: ${{ github.event_name }} | |
| GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: node scripts/detect-code-changes.mjs | |
| # === CHANGESET CHECK - only runs on PRs with JS package code changes === | |
| # Docs-only PRs don't require changesets | |
| # Workflow-only changes don't require changesets (they don't affect the package) | |
| changeset-check: | |
| name: Check for Changesets | |
| runs-on: ubuntu-latest | |
| needs: [detect-changes] | |
| if: github.event_name == 'pull_request' && needs.detect-changes.outputs.js-package-changed == 'true' | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.x' | |
| - name: Install dependencies | |
| run: npm install | |
| - name: Check for changesets | |
| env: | |
| 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 | |
| node ../scripts/validate-changeset.mjs | |
| # === VERSION CHECK - prevents manual version modification in PRs === | |
| version-check: | |
| name: Version Modification Check | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request' | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.x' | |
| - name: Check for manual version changes | |
| working-directory: js | |
| env: | |
| GITHUB_HEAD_REF: ${{ github.head_ref }} | |
| GITHUB_BASE_REF: ${{ github.base_ref }} | |
| run: | | |
| # Skip version check for automated release PRs | |
| if [[ "${{ github.head_ref }}" == "changeset-release/"* ]]; then | |
| echo "Skipping version check for automated release PR" | |
| exit 0 | |
| fi | |
| # Check if package.json version was manually changed | |
| VERSION_DIFF=$(git diff "origin/${{ github.base_ref }}"...HEAD -- package.json | grep '"version"' || true) | |
| if [ -n "$VERSION_DIFF" ]; then | |
| echo "::error::Manual version changes in package.json are not allowed. Version bumps should be handled by the CI/CD pipeline." | |
| exit 1 | |
| fi | |
| echo "No manual version changes detected." | |
| # === LINT AND FORMAT CHECK === | |
| lint: | |
| name: Lint and Format Check | |
| runs-on: ubuntu-latest | |
| needs: [detect-changes] | |
| # Note: always() is required because detect-changes is skipped on workflow_dispatch | |
| if: | | |
| always() && !cancelled() && ( | |
| github.event_name == 'push' || | |
| github.event_name == 'workflow_dispatch' || | |
| needs.detect-changes.outputs.js-changed == 'true' || | |
| needs.detect-changes.outputs.package-changed == 'true' || | |
| needs.detect-changes.outputs.js-workflow-changed == 'true' | |
| ) | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.x' | |
| - name: Install dependencies | |
| run: npm install | |
| - name: Run ESLint | |
| run: npm run lint | |
| - name: Check formatting | |
| run: npm run format:check | |
| - name: Check file size limit | |
| run: npm run check:file-size | |
| # Test matrix: 3 runtimes (Node.js, Bun, Deno) x 3 OS (Ubuntu, macOS, Windows) | |
| test: | |
| name: Test (${{ matrix.runtime }} on ${{ matrix.os }}) | |
| runs-on: ${{ matrix.os }} | |
| needs: [detect-changes, changeset-check] | |
| # Run if: push event, OR changeset-check succeeded, OR changeset-check was skipped (docs-only PR or no JS changes) | |
| if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, macos-latest, windows-latest] | |
| runtime: [node, bun, deno] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup Node.js | |
| if: matrix.runtime == 'node' | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.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 Node.js (for npm install) | |
| if: matrix.runtime == 'bun' | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.x' | |
| - 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: npm install | |
| - name: Run tests (Bun) | |
| if: matrix.runtime == 'bun' | |
| run: bun test | |
| - name: Setup Node.js (for npm install) | |
| if: matrix.runtime == 'deno' | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.x' | |
| - name: Setup Deno | |
| if: matrix.runtime == 'deno' | |
| uses: denoland/setup-deno@v2 | |
| with: | |
| deno-version: v2.x | |
| - name: Install dependencies (Deno) | |
| if: matrix.runtime == 'deno' | |
| run: npm install | |
| - name: Run tests (Deno) | |
| if: matrix.runtime == 'deno' | |
| run: deno test --allow-read --allow-env --allow-write | |
| # Release - only runs on main after tests pass (for push events) | |
| release: | |
| name: Release | |
| needs: [lint, test] | |
| # Use always() to ensure this job runs even if changeset-check was skipped | |
| if: always() && !cancelled() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.x' | |
| registry-url: 'https://registry.npmjs.org' | |
| - name: Install dependencies | |
| run: npm install | |
| - name: Update npm for OIDC trusted publishing | |
| run: node ../scripts/setup-npm.mjs | |
| - name: Configure git identity | |
| working-directory: . | |
| run: node scripts/git-config.mjs | |
| - name: Check for changesets | |
| id: check_changesets | |
| run: | | |
| # Count changeset files (excluding README.md and config.json) | |
| CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" | wc -l) | |
| echo "Found $CHANGESET_COUNT changeset file(s)" | |
| echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT | |
| - name: Version packages and commit to main | |
| if: steps.check_changesets.outputs.has_changesets == 'true' | |
| id: version | |
| run: node ../scripts/version-and-commit.mjs --mode changeset --tag-prefix "js_" --release-label "JavaScript" | |
| - name: Check if current version needs publishing | |
| id: check_publish | |
| run: | | |
| CURRENT_VERSION=$(node -p "require('./package.json').version") | |
| PACKAGE_NAME=$(node -p "require('./package.json').name") | |
| echo "Current version: ${PACKAGE_NAME}@${CURRENT_VERSION}" | |
| # Check if this version is already on npm | |
| if npm view "${PACKAGE_NAME}@${CURRENT_VERSION}" version 2>/dev/null; then | |
| echo "Version ${CURRENT_VERSION} is already published on npm" | |
| echo "needs_publish=false" >> $GITHUB_OUTPUT | |
| else | |
| echo "Version ${CURRENT_VERSION} is NOT published on npm" | |
| echo "needs_publish=true" >> $GITHUB_OUTPUT | |
| fi | |
| echo "current_version=${CURRENT_VERSION}" >> $GITHUB_OUTPUT | |
| - name: Publish to npm | |
| if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' || steps.check_publish.outputs.needs_publish == 'true' | |
| id: publish | |
| run: node ../scripts/publish-to-npm.mjs --should-pull | |
| - name: Determine release version | |
| id: release_version | |
| run: | | |
| # Use published version if available, otherwise use current version | |
| if [ -n "${{ steps.publish.outputs.published_version }}" ]; then | |
| VERSION="${{ steps.publish.outputs.published_version }}" | |
| else | |
| VERSION="${{ steps.check_publish.outputs.current_version }}" | |
| fi | |
| echo "version=${VERSION}" >> $GITHUB_OUTPUT | |
| # Determine if we need to create tag and release | |
| TAG="js_${VERSION}" | |
| NEEDS_TAG="false" | |
| NEEDS_RELEASE="false" | |
| # Check if tag exists | |
| if ! git rev-parse "${TAG}" >/dev/null 2>&1; then | |
| NEEDS_TAG="true" | |
| fi | |
| # Check if GitHub release exists | |
| if ! gh release view "${TAG}" --repo "${{ github.repository }}" >/dev/null 2>&1; then | |
| NEEDS_RELEASE="true" | |
| fi | |
| echo "needs_tag=${NEEDS_TAG}" >> $GITHUB_OUTPUT | |
| echo "needs_release=${NEEDS_RELEASE}" >> $GITHUB_OUTPUT | |
| echo "Release version: ${VERSION}, needs_tag: ${NEEDS_TAG}, needs_release: ${NEEDS_RELEASE}" | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Ensure release tag exists | |
| if: steps.release_version.outputs.needs_tag == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| VERSION="${{ steps.release_version.outputs.version }}" | |
| TAG="js_${VERSION}" | |
| echo "Tag ${TAG} does not exist, creating it..." | |
| # Try to use the v-prefixed tag commit if it exists (migration from old tag scheme) | |
| if git rev-parse "v${VERSION}" >/dev/null 2>&1; then | |
| COMMIT=$(git rev-parse "v${VERSION}") | |
| echo "Using commit from v${VERSION} tag: ${COMMIT}" | |
| else | |
| COMMIT=$(git rev-parse HEAD) | |
| echo "Using HEAD commit: ${COMMIT}" | |
| fi | |
| git tag -a "${TAG}" "${COMMIT}" -m "Release [JavaScript] ${VERSION}" | |
| git push origin "${TAG}" | |
| echo "Created and pushed tag ${TAG}" | |
| - name: Create GitHub Release | |
| if: steps.release_version.outputs.needs_release == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node ../scripts/create-github-release.mjs --release-version "${{ steps.release_version.outputs.version }}" --repository "${{ github.repository }}" --tag-prefix "js_" --release-label "JavaScript" | |
| - name: Format GitHub release notes | |
| if: steps.release_version.outputs.needs_release == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node ../scripts/format-github-release.mjs --release-version "${{ steps.release_version.outputs.version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" --tag-prefix "js_" | |
| # Manual Instant Release | |
| instant-release: | |
| name: Instant Release | |
| if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.x' | |
| registry-url: 'https://registry.npmjs.org' | |
| - name: Install dependencies | |
| run: npm install | |
| - name: Update npm for OIDC trusted publishing | |
| run: node ../scripts/setup-npm.mjs | |
| - name: Configure git identity | |
| working-directory: . | |
| run: node scripts/git-config.mjs | |
| - name: Version packages and commit to main | |
| id: version | |
| run: node ../scripts/version-and-commit.mjs --mode instant --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}" --tag-prefix "js_" --release-label "JavaScript" | |
| - name: Publish to npm | |
| if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' | |
| id: publish | |
| run: node ../scripts/publish-to-npm.mjs | |
| - name: Determine release version | |
| id: release_version | |
| run: | | |
| # Use published version if available, otherwise use version output | |
| if [ -n "${{ steps.publish.outputs.published_version }}" ]; then | |
| VERSION="${{ steps.publish.outputs.published_version }}" | |
| elif [ -n "${{ steps.version.outputs.new_version }}" ]; then | |
| VERSION="${{ steps.version.outputs.new_version }}" | |
| else | |
| VERSION=$(node -p "require('./package.json').version") | |
| fi | |
| echo "version=${VERSION}" >> $GITHUB_OUTPUT | |
| # Check if GitHub release exists | |
| TAG="js_${VERSION}" | |
| NEEDS_RELEASE="false" | |
| if ! gh release view "${TAG}" --repo "${{ github.repository }}" >/dev/null 2>&1; then | |
| NEEDS_RELEASE="true" | |
| fi | |
| echo "needs_release=${NEEDS_RELEASE}" >> $GITHUB_OUTPUT | |
| echo "Release version: ${VERSION}, needs_release: ${NEEDS_RELEASE}" | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Create GitHub Release | |
| if: steps.release_version.outputs.needs_release == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node ../scripts/create-github-release.mjs --release-version "${{ steps.release_version.outputs.version }}" --repository "${{ github.repository }}" --tag-prefix "js_" --release-label "JavaScript" | |
| - name: Format GitHub release notes | |
| if: steps.release_version.outputs.needs_release == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node ../scripts/format-github-release.mjs --release-version "${{ steps.release_version.outputs.version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" --tag-prefix "js_" | |
| # Manual Changeset PR | |
| changeset-pr: | |
| name: Create Changeset PR | |
| if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.x' | |
| - name: Install dependencies | |
| run: npm install | |
| - name: Create changeset file | |
| run: node ../scripts/create-manual-changeset.mjs --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}" | |
| - name: Format changeset with Prettier | |
| run: npx prettier --write ".changeset/*.md" || true | |
| - name: Create Pull Request | |
| uses: peter-evans/create-pull-request@v7 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| commit-message: 'chore(js): add changeset for manual ${{ github.event.inputs.bump_type }} release' | |
| branch: changeset-manual-release-js-${{ github.run_id }} | |
| delete-branch: true | |
| title: 'chore(js): manual ${{ github.event.inputs.bump_type }} release' | |
| body: | | |
| ## Manual Release Request (JavaScript) | |
| 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 }} |