diff --git a/README.md b/README.md index 99a9cc0..e57acdf 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ workflows/ ├── php/ │ ├── pre-release/ # Pre-release validation │ └── release/ # Create PHP package release +├── javascript/ +│ ├── ci/ # Lint, typecheck, build, and optional tests for Node.js/npm libs +│ └── release/ # Publish npm package and create GitHub release ├── package/ # [DEPRECATED] Use php/ instead │ ├── pre-release/ │ └── release/ @@ -197,6 +200,123 @@ jobs: --- +## JavaScript / Node.js Workflows + +### CI (`javascript/ci`) + +Runs lint, optional tests, and mandatory typecheck and build for Node.js/npm libraries. + +**Usage:** + +```yaml +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: JavaScript CI + uses: whilesmart/workflows/javascript/ci@main + with: + token: ${{ secrets.GITHUB_TOKEN }} + # Optional overrides: + # node_version: '20.x' + # working_directory: '.' + # lint_script: 'lint' + # run_tests: 'true' +``` + +**Requirements:** + +1. `package.json` in the working directory. +2. NPM scripts: + - `lint` (or the script name passed via `lint_script`). + - `typecheck` (mandatory; can be a no-op if you are not using TypeScript). + - `build` (mandatory; used to validate that the library can be built). + - `test` or `test:ci` (required when `run_tests` is set to `'true'`, which is the default). + +--- + +### Release (`javascript/release`) + +Publishes a new npm release for Node.js libraries based on the `version` in `package.json`. + +**Features:** +- Reads the version from `package.json`. +- Optionally includes release notes from `CHANGELOG.md` (if a matching version entry exists). +- Creates and pushes a `vX.Y.Z` tag. +- Publishes to npm and creates a GitHub Release. +- Supports `dry_run` mode and custom npm `dist-tag` (e.g., `latest`, `next`, `beta`). + +**Usage (release from main):** + +```yaml +name: Release + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: JavaScript Release + uses: whilesmart/workflows/javascript/release@main + with: + token: ${{ secrets.GITHUB_TOKEN }} + npm_token: ${{ secrets.NPM_TOKEN }} + # Optional overrides: + # node_version: '20.x' + # working_directory: '.' + # release_branch: 'main' + # npm_registry: 'https://registry.npmjs.org/' + # dist_tag: 'latest' + # dry_run: 'false' +``` + +**Optional: pre-release / next channel** + +```yaml +on: + push: + branches: [next] + +jobs: + release-next: + runs-on: ubuntu-latest + steps: + - name: JavaScript Release (next) + uses: whilesmart/workflows/javascript/release@main + with: + token: ${{ secrets.GITHUB_TOKEN }} + npm_token: ${{ secrets.NPM_TOKEN }} + release_branch: 'next' + dist_tag: 'next' +``` + +**Requirements:** + +1. `package.json` with a valid `version` field. +2. The following npm scripts in `package.json` (all mandatory): + - `lint` (or override via `lint_script` input) + - `typecheck` (can be a no-op if not using TypeScript) + - `build` (can be a no-op if no build step is needed) +3. `test` or `test:ci` script (optional, controlled by `run_tests` input) +4. `CHANGELOG.md` (optional but recommended; if present, include an entry like `## [X.Y.Z] - YYYY-MM-DD` for the version). +5. `NPM_TOKEN` secret configured with publish permissions for the target npm registry. + +--- + ## Adding New Languages To add workflows for a new language: diff --git a/javascript/ci/action.yml b/javascript/ci/action.yml new file mode 100644 index 0000000..e72ac9f --- /dev/null +++ b/javascript/ci/action.yml @@ -0,0 +1,123 @@ +name: 'JavaScript CI' +description: 'Run lint, typecheck, optional tests, and build for Node.js/npm projects' + +inputs: + token: + description: 'GitHub token for authentication' + required: true + node_version: + description: 'Node.js version to use' + required: false + default: 'lts/*' + working_directory: + description: 'Directory containing package.json (relative to repository root)' + required: false + default: '.' + lint_script: + description: 'NPM script name to run for linting' + required: false + default: 'lint' + run_tests: + description: 'Whether to run the test script (test or test:ci)' + required: false + default: 'true' + +runs: + using: 'composite' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ inputs.token }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node_version }} + cache: 'npm' + cache-dependency-path: ${{ inputs.working_directory }}/package-lock.json + + - name: Install dependencies + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + if [ ! -f "package.json" ]; then + echo "❌ package.json not found in $PWD" + exit 1 + fi + + if [ -f "package-lock.json" ]; then + echo "📦 Using npm ci" + npm ci + else + echo "📦 Using npm install" + npm install + fi + + - name: Run lint + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + if jq -e ".scripts[\"${{ inputs.lint_script }}\"]" package.json >/dev/null; then + echo "🔎 Running npm run ${{ inputs.lint_script }}..." + npm run ${{ inputs.lint_script }} + else + echo "❌ No \"${{ inputs.lint_script }}\" script defined in package.json" + echo " Please add it or override the lint_script input." + exit 1 + fi + + - name: Run typecheck + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + if jq -e '.scripts["typecheck"]' package.json >/dev/null; then + echo "🔡 Running npm run typecheck..." + npm run typecheck + else + echo "❌ No \"typecheck\" script defined in package.json" + echo " Please add a typecheck script (it can be a no-op if you are not using TypeScript)." + exit 1 + fi + + - name: Run tests + if: ${{ inputs.run_tests == 'true' }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + TEST_SCRIPT="" + if jq -e '.scripts["test:ci"]' package.json >/dev/null; then + TEST_SCRIPT="test:ci" + elif jq -e '.scripts["test"]' package.json >/dev/null; then + TEST_SCRIPT="test" + else + echo "❌ No \"test\" or \"test:ci\" script defined in package.json" + echo " Please define one of them to use the JavaScript CI action." + exit 1 + fi + + echo "🧪 Running npm run $TEST_SCRIPT..." + npm run "$TEST_SCRIPT" + + - name: Run build + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + if jq -e '.scripts["build"]' package.json >/dev/null; then + echo "🏗️ Running npm run build..." + npm run build + else + echo "❌ No \"build\" script defined in package.json" + echo " Please add a build script (it can be a no-op if your project does not need a build step)." + exit 1 + fi + + - name: Summary + if: success() + shell: bash + run: | + echo "## ✅ JavaScript CI Summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- Node.js version: \`${{ inputs.node_version }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Working directory: \`${{ inputs.working_directory }}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/javascript/release/action.yml b/javascript/release/action.yml new file mode 100644 index 0000000..7a279a8 --- /dev/null +++ b/javascript/release/action.yml @@ -0,0 +1,235 @@ +name: 'JavaScript Release' +description: 'Publish npm package and create a GitHub release based on package.json version' + +inputs: + token: + description: 'GitHub token for authentication (used for checkout, pushing tags, and creating releases)' + required: true + npm_token: + description: 'NPM auth token for publishing to the registry' + required: true + node_version: + description: 'Node.js version to use' + required: false + default: 'lts/*' + working_directory: + description: 'Directory containing package.json (relative to repository root)' + required: false + default: '.' + release_branch: + description: 'Branch from which releases are allowed (e.g., main, next)' + required: false + default: 'main' + npm_registry: + description: 'NPM registry URL' + required: false + default: 'https://registry.npmjs.org/' + dist_tag: + description: 'npm dist-tag to publish under (e.g., latest, next, beta)' + required: false + default: 'latest' + dry_run: + description: 'If true, do not tag, publish, or create a GitHub Release' + required: false + default: 'false' + +outputs: + version: + description: 'The version being released (from package.json)' + value: ${{ steps.get_version.outputs.version }} + tag: + description: 'The git tag for the release' + value: ${{ steps.get_version.outputs.tag }} + release_url: + description: 'URL of the created GitHub release (if any)' + value: ${{ steps.create_release.outputs.url }} + +runs: + using: 'composite' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ inputs.token }} + + - name: Configure Git + shell: bash + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + - name: Ensure running on release branch + shell: bash + run: | + CURRENT_REF="${GITHUB_REF#refs/heads/}" + if [ "$CURRENT_REF" != "${{ inputs.release_branch }}" ]; then + echo "❌ JavaScript Release can only run on branch '${{ inputs.release_branch }}' (current: '$CURRENT_REF')" + exit 1 + fi + + - name: Extract version from package.json + id: get_version + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + if [ ! -f "package.json" ]; then + echo "❌ package.json not found in $PWD" + exit 1 + fi + + VERSION=$(jq -r '.version // empty' package.json) + + if [ -z "$VERSION" ]; then + echo "❌ No version found in package.json" + echo " Please set package.json \"version\" before releasing." + exit 1 + fi + + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "❌ Invalid semver format: $VERSION" + exit 1 + fi + + TAG="v$VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "✅ Found version: $VERSION" + + - name: Extract changelog (optional) + id: get_changelog + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + VERSION="${{ steps.get_version.outputs.version }}" + + if [ ! -f "CHANGELOG.md" ]; then + echo "ℹ️ No CHANGELOG.md found, skipping release notes extraction" + echo "release_notes=Release $VERSION" >> $GITHUB_OUTPUT + exit 0 + fi + + TEMP_FILE=$(mktemp) + + awk -v version="$VERSION" ' + BEGIN { found=0; printing=0 } + /^## \[/ { + if (found && printing) exit + if ($0 ~ "\\[" version "\\]") { + found=1 + printing=1 + next + } + } + found && printing && /^## \[/ { exit } + found && printing { print } + ' CHANGELOG.md > "$TEMP_FILE" + + if [ ! -s "$TEMP_FILE" ]; then + echo "ℹ️ No changelog entry for version $VERSION" + echo "release_notes=Release $VERSION" >> $GITHUB_OUTPUT + else + { + echo 'release_notes<> $GITHUB_OUTPUT + echo "✅ Found changelog entry for version $VERSION" + fi + + rm "$TEMP_FILE" + + - name: Check if tag already exists + shell: bash + run: | + TAG="${{ steps.get_version.outputs.tag }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "❌ Tag $TAG already exists!" + exit 1 + fi + echo "✅ Tag $TAG does not exist, proceeding..." + + - name: Set up Node.js + if: ${{ inputs.dry_run == 'false' }} + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node_version }} + registry-url: ${{ inputs.npm_registry }} + + - name: Create and push tag + if: ${{ inputs.dry_run == 'false' }} + shell: bash + run: | + TAG="${{ steps.get_version.outputs.tag }}" + + git tag -a "$TAG" -m "Release $TAG" + echo "✅ Created tag $TAG" + + git push origin "$TAG" + echo "✅ Pushed tag to origin" + + - name: Build and publish to npm + if: ${{ inputs.dry_run == 'false' }} + shell: bash + env: + NODE_AUTH_TOKEN: ${{ inputs.npm_token }} + working-directory: ${{ inputs.working_directory }} + run: | + echo "📦 Installing dependencies before publish..." + if [ -f "package-lock.json" ]; then + npm ci + else + npm install + fi + + if jq -e '.scripts["build"]' package.json >/dev/null; then + echo "🏗️ Running npm run build before publish..." + npm run build + else + echo "❌ No \"build\" script defined in package.json" + echo " Please add a build script (it can be a no-op if your project does not need a build step)." + exit 1 + fi + + echo "🚀 Publishing package to npm with dist-tag '${{ inputs.dist_tag }}'..." + npm publish --tag "${{ inputs.dist_tag }}" --registry "${{ inputs.npm_registry }}" + + - name: Create GitHub Release + if: ${{ inputs.dry_run == 'false' }} + id: create_release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.get_version.outputs.tag }} + name: Release ${{ steps.get_version.outputs.tag }} + body: | + ## Release Notes + + ${{ steps.get_changelog.outputs.release_notes }} + draft: false + prerelease: ${{ contains(steps.get_version.outputs.version, '-') }} + token: ${{ inputs.token }} + + - name: Dry-run summary (no publish) + if: ${{ inputs.dry_run == 'true' }} + shell: bash + run: | + VERSION="${{ steps.get_version.outputs.version }}" + TAG="${{ steps.get_version.outputs.tag }}" + echo "## 🚧 JavaScript Release (dry-run)" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- Version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Tag: \`$TAG\`" >> "$GITHUB_STEP_SUMMARY" + echo "- No tags, npm publish, or GitHub Release were performed because dry_run=true" >> "$GITHUB_STEP_SUMMARY" + + - name: Release summary + if: ${{ inputs.dry_run == 'false' }} + shell: bash + run: | + VERSION="${{ steps.get_version.outputs.version }}" + TAG="${{ steps.get_version.outputs.tag }}" + echo "## 🎉 JavaScript Release Summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- Released version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Git tag: \`$TAG\`" >> "$GITHUB_STEP_SUMMARY" + echo "- npm dist-tag: \`${{ inputs.dist_tag }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Branch: \`${{ inputs.release_branch }}\`" >> "$GITHUB_STEP_SUMMARY"