diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d3f7774 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,196 @@ +name: Release + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run only (no git push, no tags, no npm publish)' + required: false + default: true + type: boolean + +permissions: + contents: write + id-token: write + issues: write + pull-requests: write + +# Allow GitHub Actions to bypass branch protection +# This is required for semantic-release to push version updates + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Verify admin permissions + run: | + # Use the repository's permission endpoint which works for both personal and org repos + RESPONSE=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission") + + # Extract permission using jq if available, otherwise use grep + if command -v jq &> /dev/null; then + PERMISSION=$(echo "$RESPONSE" | jq -r '.permission // empty') + else + PERMISSION=$(echo "$RESPONSE" | grep -o '"permission":"[^"]*"' | head -1 | cut -d'"' -f4) + fi + + if [ -z "$PERMISSION" ]; then + echo "Warning: Could not determine permission level. Response: $RESPONSE" + echo "Note: workflow_dispatch requires write access, proceeding..." + exit 0 + fi + + if [ "$PERMISSION" != "admin" ]; then + echo "Error: Only repository admins can trigger releases. Current permission: $PERMISSION" + exit 1 + fi + + echo "✓ Verified admin permission for ${{ github.actor }}" + + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + fetch-tags: true + token: ${{ secrets.RELEASE_TOKEN }} + + - name: Setup git branch + run: | + git fetch --all --tags --force + git fetch origin '+refs/notes/*:refs/notes/*' || true + git checkout -B main + git branch --set-upstream-to=origin/main main + + - name: Ensure master branch exists (for semantic-release validation) + run: | + # semantic-release requires at least one release branch that exists; repo uses main, we declare "master" + if ! git ls-remote --heads origin master 2>/dev/null | grep -q .; then + git checkout -b master + git push origin master + git checkout main + fi + + - uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + always-auth: true + + - run: npm ci + + - run: npm test --if-present + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Add semantic-release note (dry run only) + if: github.event.inputs.dry_run == 'true' + run: | + set -e + if ! git rev-parse --verify v1.0.0-beta.1 >/dev/null 2>&1; then exit 0; fi + git notes --ref semantic-release-v1.0.0-beta.1 add -f -m '{"channels":["beta"]}' v1.0.0-beta.1 + echo "Added semantic-release note to v1.0.0-beta.1 (local only for dry run)." + + - name: Dry run mode notice + if: github.event.inputs.dry_run == 'true' + run: | + echo "==============================================" + echo " DRY RUN MODE - No commits, tags, or publish" + echo "==============================================" + echo "Semantic-release will show what WOULD happen." + echo "To perform a real release, run again and uncheck 'Dry run only'." + echo "" + + - name: Ensure v1.0.0-beta.1 exists locally (dry run only) + if: github.event.inputs.dry_run == 'true' + run: | + if ! git rev-parse --verify "v1.0.0-beta.1" >/dev/null 2>&1; then + # So semantic-release sees a "previous release" and suggests 1.0.0-beta.2 for new commits + PARENT=$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD) + git tag -a "v1.0.0-beta.1" "$PARENT" -m "chore: initial beta release (dry-run placeholder)" + echo "Created local tag v1.0.0-beta.1 at $PARENT so semantic-release can compute next version (1.0.0-beta.2)." + else + echo "Tag v1.0.0-beta.1 already exists." + fi + + - name: Get version before semantic-release + id: version-before + run: | + VERSION_BEFORE=$(node -p "require('./package.json').version") + echo "version=$VERSION_BEFORE" >> $GITHUB_OUTPUT + echo "Current version: $VERSION_BEFORE" + + - name: Release with semantic-release + id: release + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + set -e + if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then + echo "Running semantic-release in DRY RUN mode..." + npx semantic-release --dry-run 2>&1 | tee semantic-release.log || true + grep -oE "The next release version is [^[:space:]]+" semantic-release.log 2>/dev/null | sed 's/The next release version is //' > next-version.txt || echo "" > next-version.txt + else + npx semantic-release + fi + + # npm publish uses OIDC (id-token: write + --provenance). No NPM_TOKEN needed. + # Require on npmjs.com: Package → Package settings → Trusted publishers → + # Add: GitHub Actions, org cloudinary-devs, repo create-cloudinary-next, workflow release.yml + # npm trusted publishing (OIDC) requires npm CLI 11.5.1+; Node 20 ships with npm 9.x. + # Force OIDC-only: override NPM_CONFIG_USERCONFIG so npm ignores setup-node's .npmrc (which may reference a stale token). + - name: Publish to npm using trusted publishing + if: github.event.inputs.dry_run != 'true' + env: + NODE_AUTH_TOKEN: '' + NPM_TOKEN: '' + NPM_CONFIG_USERCONFIG: '${{ runner.temp }}/.npmrc-oidc' + run: | + echo "=== Publishing to npm with trusted publishing (OIDC) ===" + unset NODE_AUTH_TOKEN NPM_TOKEN 2>/dev/null || true + # Config that has only registry — no _authToken — so npm uses OIDC + echo "registry=https://registry.npmjs.org/" > "$NPM_CONFIG_USERCONFIG" + # OIDC for publish requires npm 11.5.1+ (Node 20 ships with npm 9.x) + npm install -g npm@latest + npm --version + + # Get versions + VERSION_BEFORE="${{ steps.version-before.outputs.version }}" + VERSION_AFTER=$(node -p "require('./package.json').version") + + echo "Version before: $VERSION_BEFORE" + echo "Version after: $VERSION_AFTER" + + # Only publish if semantic-release created a new version + if [ "$VERSION_BEFORE" != "$VERSION_AFTER" ]; then + echo "✓ New version detected: $VERSION_AFTER" + echo "Publishing to npm..." + + # Publish using npm publish which supports OIDC/trusted publishing + # --tag latest so installers get the most recent version (npm i create-cloudinary-next / npx create-cloudinary-next) + npm publish --provenance --access public --tag latest + echo "✓ Published $VERSION_AFTER to npm" + else + echo "No version change detected (version: $VERSION_AFTER)" + echo "Skipping npm publish - no new release was created" + fi + + - name: Dry run - skip npm publish + if: github.event.inputs.dry_run == 'true' + run: | + echo "==============================================" + echo " DRY RUN - Skipping npm publish" + echo "==============================================" + NEXT_VERSION=$(cat next-version.txt 2>/dev/null || echo "") + if [ -n "$NEXT_VERSION" ]; then + echo "Version that WOULD have been published: $NEXT_VERSION" + else + echo "Version that WOULD have been published: (check semantic-release output above; might be no new release)" + echo "Current package.json: $(node -p "require('./package.json').version")" + fi + echo "" + echo "To publish for real, run the workflow again with 'Dry run only' unchecked." diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..0398b7a --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no -- commitlint --edit ${1} diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..f580732 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,30 @@ +{ + "branches": [ + "master", + { + "name": "main", + "prerelease": "beta" + } + ], + "tagFormat": "v${version}", + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/changelog", + [ + "@semantic-release/npm", + { + "npmPublish": false, + "tarballDir": "dist" + } + ], + [ + "@semantic-release/git", + { + "assets": ["package.json", "CHANGELOG.md"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ], + "@semantic-release/github" + ] +} diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..ee9db56 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,23 @@ +export default { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [ + 2, + 'always', + [ + 'feat', // New feature + 'fix', // Bug fix + 'docs', // Documentation changes + 'style', // Code style changes (formatting, etc.) + 'refactor', // Code refactoring + 'perf', // Performance improvements + 'test', // Adding or updating tests + 'build', // Build system changes + 'ci', // CI configuration changes + 'chore', // Other changes that don't modify src or test files + 'revert', // Revert a previous commit + ], + ], + 'subject-case': [2, 'never', ['start-case', 'pascal-case', 'upper-case']], + }, +}; diff --git a/package.json b/package.json index 459b516..eae4404 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Scaffold a Cloudinary Next.js project with interactive setup", "type": "module", "bin": { - "create-cloudinary-react": "./cli.js" + "create-cloudinary-next": "./cli.js" }, "keywords": [ "cloudinary", @@ -14,6 +14,12 @@ ], "author": "", "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "semantic-release": "semantic-release" + }, "repository": { "type": "git", "url": "https://github.com/cloudinary-devs/create-cloudinary-next.git" @@ -21,5 +27,13 @@ "homepage": "https://github.com/cloudinary-devs/create-cloudinary-next#readme", "bugs": { "url": "https://github.com/cloudinary-devs/create-cloudinary-next/issues" + }, + "devDependencies": { + "@commitlint/cli": "^19.6.0", + "@commitlint/config-conventional": "^19.6.0", + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/git": "^10.0.1", + "husky": "^9.1.7", + "semantic-release": "^23.0.0" } } \ No newline at end of file