Created
September 7, 2025 04:33
-
-
Save azu/2864f536002c6e99f9c57a379b093f40 to your computer and use it in GitHub Desktop.
setup script GitHub and Npm Trusted Publish
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
#!/usr/bin/env node --experimental-strip-types | |
import { promises as fs, readFileSync } from 'node:fs'; | |
import { join } from 'node:path'; | |
import { execSync } from 'node:child_process'; | |
import { existsSync } from 'node:fs'; | |
// 1Password account configuration | |
const OP_ACCOUNT = '**********YOUR********.1password.com'; | |
// Check if 1Password CLI is available | |
function has1PasswordCLI(): boolean { | |
try { | |
execSync(`op --account "${OP_ACCOUNT}" --version`, { stdio: 'ignore' }); | |
return true; | |
} catch { | |
return false; | |
} | |
} | |
// Detect package manager | |
function detectPackageManager(): 'npm' | 'yarn' | 'pnpm' { | |
// First check package.json's packageManager field | |
try { | |
const packageJson = JSON.parse(readFileSync('package.json', 'utf-8')); | |
if (packageJson.packageManager) { | |
const manager = packageJson.packageManager.split('@')[0]; | |
if (manager === 'pnpm') return 'pnpm'; | |
if (manager === 'yarn') return 'yarn'; | |
if (manager === 'npm') return 'npm'; | |
} | |
} catch { | |
// Ignore error and fall back to lock file detection | |
} | |
// Fall back to lock file detection | |
if (existsSync('pnpm-lock.yaml')) return 'pnpm'; | |
if (existsSync('yarn.lock')) return 'yarn'; | |
return 'npm'; | |
} | |
// Get install command based on package manager | |
function getInstallCommand(pm: string): string { | |
switch (pm) { | |
case 'pnpm': return 'pnpm install --frozen-lockfile'; | |
case 'yarn': return 'yarn install --frozen-lockfile'; | |
default: return 'npm ci'; | |
} | |
} | |
// Get version bump command | |
function getVersionCommand(): string { | |
return 'npm version "$VERSION_TYPE" --no-git-tag-version'; | |
} | |
// Get build command | |
function getBuildCommand(pm: string): string { | |
switch (pm) { | |
case 'yarn': return 'yarn run build'; | |
case 'pnpm': return 'pnpm run build'; | |
default: return 'npm run build'; | |
} | |
} | |
// Get publish command | |
function getPublishCommand(pm: string): string { | |
switch (pm) { | |
case 'yarn': return 'npm publish --provenance --access public'; | |
case 'pnpm': return 'pnpm publish --provenance --access public'; | |
default: return 'npm publish --provenance --access public'; | |
} | |
} | |
const packageManager = detectPackageManager(); | |
function generateCreateReleasePRTemplate(pm: string): string { | |
return `name: Create Release PR | |
on: | |
workflow_dispatch: | |
inputs: | |
version: | |
description: 'Version type' | |
required: true | |
type: choice | |
options: | |
- patch | |
- minor | |
- major | |
jobs: | |
create-release-pr: | |
runs-on: ubuntu-latest | |
permissions: | |
contents: write | |
pull-requests: write | |
steps: | |
- name: Checkout | |
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | |
with: | |
persist-credentials: false | |
- name: Configure Git | |
run: | | |
git config user.name "github-actions[bot]" | |
git config user.email "github-actions[bot]@users.noreply.github.com" | |
${pm === 'pnpm' ? ` | |
- name: Install pnpm | |
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0` : ''} | |
- name: Setup Node.js | |
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | |
with: | |
node-version: 'lts/*' | |
# No need to install dependencies - npm version works without them | |
- name: Version bump | |
id: version | |
run: | | |
npm version "$VERSION_TYPE" --no-git-tag-version | |
VERSION=$(jq -r '.version' package.json) | |
echo "version=$VERSION" >> $GITHUB_OUTPUT | |
env: | |
VERSION_TYPE: \${{ github.event.inputs.version }} | |
- name: Get release notes | |
id: release-notes | |
run: | | |
# Get the default branch | |
DEFAULT_BRANCH=$(gh api "repos/$GITHUB_REPOSITORY" --jq '.default_branch') | |
# Get the latest release tag using GitHub API | |
# Use the exit code to determine if a release exists | |
if LAST_TAG=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq '.tag_name' 2>/dev/null); then | |
echo "Previous release found: $LAST_TAG" | |
else | |
LAST_TAG="" | |
echo "No previous releases found - this will be the first release" | |
fi | |
# Generate release notes - only include previous_tag_name if we have a valid previous tag | |
echo "Generating release notes for tag: v$VERSION" | |
if [ -n "$LAST_TAG" ]; then | |
echo "Using previous tag: $LAST_TAG" | |
RELEASE_NOTES=$(gh api \\ | |
--method POST \\ | |
-H "Accept: application/vnd.github+json" \\ | |
"/repos/$GITHUB_REPOSITORY/releases/generate-notes" \\ | |
-f "tag_name=v$VERSION" \\ | |
-f "target_commitish=$DEFAULT_BRANCH" \\ | |
-f "previous_tag_name=$LAST_TAG" \\ | |
--jq '.body') | |
else | |
echo "Generating notes from all commits" | |
RELEASE_NOTES=$(gh api \\ | |
--method POST \\ | |
-H "Accept: application/vnd.github+json" \\ | |
"/repos/$GITHUB_REPOSITORY/releases/generate-notes" \\ | |
-f "tag_name=v$VERSION" \\ | |
-f "target_commitish=$DEFAULT_BRANCH" \\ | |
--jq '.body') | |
fi | |
# Set release notes as environment variable | |
echo "RELEASE_NOTES<<EOF" >> $GITHUB_ENV | |
echo "$RELEASE_NOTES" >> $GITHUB_ENV | |
echo "EOF" >> $GITHUB_ENV | |
env: | |
GH_TOKEN: \${{ github.token }} | |
VERSION: \${{ steps.version.outputs.version }} | |
GITHUB_REPOSITORY: \${{ github.repository }} | |
- name: Create Pull Request | |
uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5.0.2 | |
with: | |
branch: release/v\${{ steps.version.outputs.version }} | |
delete-branch: true | |
title: "Release v\${{ steps.version.outputs.version }}" | |
body: | | |
\${{ env.RELEASE_NOTES }} | |
commit-message: "chore: release v\${{ steps.version.outputs.version }}" | |
labels: | | |
Type: Release | |
assignees: \${{ github.actor }} | |
draft: true | |
`; | |
} | |
function generateReleaseTemplate(pm: string, hasBuildScript: boolean): string { | |
const installCmd = getInstallCommand(pm); | |
const buildCmd = getBuildCommand(pm); | |
const publishCmd = getPublishCommand(pm); | |
return `name: Release | |
on: | |
pull_request: | |
branches: | |
- master | |
- main | |
types: | |
- closed | |
jobs: | |
release: | |
if: | | |
github.event.pull_request.merged == true && | |
contains(github.event.pull_request.labels.*.name, 'Type: Release') | |
runs-on: ubuntu-latest | |
permissions: | |
contents: write | |
id-token: write # OIDC | |
pull-requests: write # PR comment | |
steps: | |
- name: Checkout | |
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | |
with: | |
persist-credentials: false | |
- name: Get package info | |
id: package | |
run: | | |
VERSION=$(jq -r '.version' package.json) | |
PACKAGE_NAME=$(jq -r '.name' package.json) | |
echo "version=$VERSION" >> $GITHUB_OUTPUT | |
echo "name=$PACKAGE_NAME" >> $GITHUB_OUTPUT | |
- name: Check if tag exists | |
id: tag-check | |
run: | | |
if git rev-parse "v$VERSION" >/dev/null 2>&1; then | |
echo "exists=true" >> $GITHUB_OUTPUT | |
else | |
echo "exists=false" >> $GITHUB_OUTPUT | |
fi | |
env: | |
VERSION: \${{ steps.package.outputs.version }} | |
${pm === 'pnpm' ? ` | |
- name: Install pnpm | |
if: steps.tag-check.outputs.exists == 'false' | |
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0` : ''} | |
- name: Setup Node.js | |
if: steps.tag-check.outputs.exists == 'false' | |
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | |
with: | |
node-version: 'lts/*' | |
registry-url: 'https://registry.npmjs.org' | |
- name: Install latest npm | |
if: steps.tag-check.outputs.exists == 'false' | |
run: | | |
echo "Current npm version: $(npm -v)" | |
npm install -g npm@latest | |
echo "Updated npm version: $(npm -v)" | |
- name: Install dependencies | |
if: steps.tag-check.outputs.exists == 'false' | |
run: ${installCmd} | |
${hasBuildScript ? ` | |
- name: Build package | |
if: steps.tag-check.outputs.exists == 'false' | |
run: ${buildCmd} | |
` : ''} | |
- name: Publish to npm with provenance | |
if: steps.tag-check.outputs.exists == 'false' | |
run: ${publishCmd} | |
- name: Create GitHub Release with tag | |
id: create-release | |
if: steps.tag-check.outputs.exists == 'false' | |
run: | | |
RELEASE_URL=$(gh release create "v$VERSION" \\ | |
--title "v$VERSION" \\ | |
--target "$SHA" \\ | |
--notes "$PR_BODY") | |
echo "url=$RELEASE_URL" >> $GITHUB_OUTPUT | |
env: | |
GH_TOKEN: \${{ github.token }} | |
VERSION: \${{ steps.package.outputs.version }} | |
SHA: \${{ github.sha }} | |
PR_BODY: \${{ github.event.pull_request.body }} | |
- name: Comment on PR - Success | |
if: | | |
always() && | |
github.event_name == 'pull_request' && | |
steps.tag-check.outputs.exists == 'false' && | |
success() | |
run: | | |
gh pr comment "$PR_NUMBER" \\ | |
--body "✅ **Release v$VERSION completed successfully!** | |
- 📦 npm package: https://www.npmjs.com/package/$PACKAGE_NAME/v/$VERSION | |
- 🏷️ GitHub Release: $RELEASE_URL | |
- 🔗 Workflow run: $SERVER_URL/$REPOSITORY/actions/runs/$RUN_ID" | |
env: | |
GH_TOKEN: \${{ github.token }} | |
PR_NUMBER: \${{ github.event.pull_request.number }} | |
VERSION: \${{ steps.package.outputs.version }} | |
PACKAGE_NAME: \${{ steps.package.outputs.name }} | |
RELEASE_URL: \${{ steps.create-release.outputs.url }} | |
SERVER_URL: \${{ github.server_url }} | |
REPOSITORY: \${{ github.repository }} | |
RUN_ID: \${{ github.run_id }} | |
- name: Comment on PR - Failure | |
if: | | |
always() && | |
github.event_name == 'pull_request' && | |
steps.tag-check.outputs.exists == 'false' && | |
failure() | |
run: | | |
gh pr comment "$PR_NUMBER" \\ | |
--body "❌ **Release v$VERSION failed** | |
Please check the workflow logs for details. | |
🔗 Workflow run: $SERVER_URL/$REPOSITORY/actions/runs/$RUN_ID" | |
env: | |
GH_TOKEN: \${{ github.token }} | |
PR_NUMBER: \${{ github.event.pull_request.number }} | |
VERSION: \${{ steps.package.outputs.version }} | |
SERVER_URL: \${{ github.server_url }} | |
REPOSITORY: \${{ github.repository }} | |
RUN_ID: \${{ github.run_id }} | |
`; | |
} | |
async function setupReleaseWorkflow() { | |
console.log('🚀 GitHub Actions Release Workflow Setup\n'); | |
console.log(`📦 Detected package manager: ${packageManager}\n`); | |
// Check if package.json exists | |
const packageJsonPath = join(process.cwd(), 'package.json'); | |
let packageName = ''; | |
let hasBuildScript = false; | |
try { | |
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')); | |
packageName = packageJson.name; | |
hasBuildScript = !!packageJson.scripts?.build; | |
console.log(`📦 Package: ${packageName}`); | |
console.log(`🔨 Build script: ${hasBuildScript ? 'found' : 'not found'}`); | |
} catch (error) { | |
console.error('❌ package.json not found. Please run this command in a Node.js project.'); | |
process.exit(1); | |
} | |
// Create .github/workflows directory | |
const workflowsDir = join(process.cwd(), '.github', 'workflows'); | |
await fs.mkdir(workflowsDir, { recursive: true }); | |
console.log(`✅ Created directory: ${workflowsDir}`); | |
// Write workflow files | |
const createReleasePrPath = join(workflowsDir, 'create-release-pr.yml'); | |
const releasePath = join(workflowsDir, 'release.yml'); | |
await fs.writeFile(createReleasePrPath, generateCreateReleasePRTemplate(packageManager)); | |
console.log(`✅ Created: ${createReleasePrPath}`); | |
await fs.writeFile(releasePath, generateReleaseTemplate(packageManager, hasBuildScript)); | |
console.log(`✅ Created: ${releasePath}`); | |
// Update GitHub Actions versions to latest using pinact | |
console.log('\n🔄 Updating GitHub Actions to latest versions...'); | |
try { | |
execSync(`pinact run --update ${createReleasePrPath} ${releasePath}`, { | |
cwd: process.cwd(), | |
stdio: 'inherit' | |
}); | |
console.log('✅ GitHub Actions updated to latest versions'); | |
} catch (error) { | |
console.log('⚠️ Could not update GitHub Actions versions (pinact not installed?)'); | |
console.log(' Install pinact: npm install -g pinact'); | |
} | |
// Commit workflow files | |
console.log('\n📝 Committing workflow files...'); | |
try { | |
// Check if there are changes to commit | |
const gitStatus = execSync('git status --porcelain .github/workflows/', { encoding: 'utf-8' }); | |
if (gitStatus.trim()) { | |
execSync('git add .github/workflows/create-release-pr.yml .github/workflows/release.yml', { stdio: 'inherit' }); | |
const commitMessage = `CI: add npm Trusted Publisher workflows | |
- create-release-pr.yml: Creates release PRs with version bump and release notes | |
- release.yml: Publishes to npm using Trusted Publisher (OIDC) when PR is merged | |
- No npm tokens required - uses GitHub OIDC for authentication`; | |
execSync(`git commit -m "${commitMessage}"`, { stdio: 'inherit' }); | |
console.log('✅ Workflow files committed'); | |
} else { | |
console.log('ℹ️ No changes to commit (workflow files already exist)'); | |
} | |
} catch (error) { | |
console.log('⚠️ Could not commit workflow files automatically'); | |
console.log(' Please commit them manually: git add .github/workflows/ && git commit -m "ci: add release workflows"'); | |
} | |
// Get GitHub repository info | |
let repoOwner = ''; | |
let repoName = ''; | |
try { | |
const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim(); | |
const match = remoteUrl.match(/github\.com[:/]([^/]+)\/([^.]+)/); | |
if (match) { | |
repoOwner = match[1]; | |
repoName = match[2]; | |
} | |
} catch (error) { | |
console.log('⚠️ Could not determine GitHub repository from git remote'); | |
} | |
// GitHub Actions PR permissions reminder | |
if (repoOwner && repoName) { | |
console.log('\n⚠️ GitHub Actions Permissions Required'); | |
console.log(' Please ensure the following setting is enabled:'); | |
console.log(` 🔗 https://github.com/${repoOwner}/${repoName}/settings/actions`); | |
console.log(' → Workflow permissions → ✅ Allow GitHub Actions to create and approve pull requests'); | |
console.log(' (This setting cannot be enabled via API and must be configured manually)'); | |
} | |
// Display NPM Trusted Publisher setup guide | |
console.log('\n' + '='.repeat(60)); | |
console.log('📋 NPM Trusted Publisher Setup Guide (OIDC)'); | |
console.log('='.repeat(60)); | |
console.log('\n1️⃣ Open npm package settings:'); | |
console.log(` 🔗 https://www.npmjs.com/package/${packageName}/access`); | |
console.log('\n2️⃣ Configure GitHub Actions Publishing:'); | |
console.log(' • Click "Manage" → "Publish with a Trusted Publisher"'); | |
console.log(' • Select "GitHub Actions" as the publisher'); | |
console.log('\n3️⃣ Add the following configuration:'); | |
console.log(' ┌─────────────────────────────────────────────┐'); | |
console.log(' │ Repository owner: ' + (repoOwner || '<your-github-username>').padEnd(25) + '│'); | |
console.log(' │ Repository name: ' + (repoName || '<your-repo-name>').padEnd(25) + '│'); | |
console.log(' │ Workflow name: release.yml │'); | |
console.log(' │ Environment: (leave empty) │'); | |
console.log(' └─────────────────────────────────────────────┘'); | |
console.log('\n ℹ️ This enables OIDC (OpenID Connect) authentication'); | |
console.log(' between GitHub Actions and npm for secure publishing'); | |
console.log(' without storing npm tokens as secrets.'); | |
console.log('\n4️⃣ Save the configuration'); | |
console.log('\n' + '='.repeat(60)); | |
console.log('📚 Documentation:'); | |
console.log(' • NPM Trusted Publishers: https://docs.npmjs.com/trusted-publishers'); | |
console.log(' • GitHub OIDC: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect'); | |
console.log(' • Provenance: https://docs.npmjs.com/generating-provenance-statements'); | |
console.log('\n' + '='.repeat(60)); | |
console.log('✅ Workflow files have been created successfully!'); | |
// Check for 1Password and create item if available | |
if (has1PasswordCLI() && repoOwner && repoName) { | |
try { | |
const itemTitle = `Trusted Publisher - ${packageName}`; | |
const npmUrl = `https://www.npmjs.com/package/${packageName}/access`; | |
// Check if item already exists | |
let itemExists = false; | |
try { | |
execSync(`op --account "${OP_ACCOUNT}" item get "${itemTitle}" --format=json`, { | |
stdio: 'ignore' | |
}); | |
itemExists = true; | |
console.log(`\n✅ 1Password item already exists: "${itemTitle}"`); | |
console.log(' Use 1Password extension to fill the form'); | |
} catch { | |
// Item doesn't exist, proceed to create | |
} | |
if (!itemExists) { | |
console.log('\n🔑 Creating 1Password item...'); | |
// Create LOGIN type 1Password item with HTML field matching | |
try { | |
// Create LOGIN item with custom fields matching HTML IDs | |
// Not using --url flag to prevent auto-fill, using custom field instead | |
const createCmd = [ | |
`op --account "${OP_ACCOUNT}" item create`, | |
`--category="LOGIN"`, | |
`--title="${itemTitle}"`, | |
// Store URL as custom field (not used for autofill according to 1Password docs) | |
`"NPM Access URL[url]=${npmUrl}"`, | |
// Custom fields matching exact HTML IDs | |
`"oidc_repositoryOwner[text]=${repoOwner}"`, | |
`"oidc_repositoryName[text]=${repoName}"`, | |
`"oidc_workflowName[text]=release.yml"`, | |
`"oidc_githubEnvironmentName[text]="` | |
].join(' '); | |
execSync(createCmd, { encoding: 'utf-8', stdio: 'ignore' }); | |
console.log(`✅ 1Password item created: "${itemTitle}"`); | |
console.log(' URL stored as custom field (not used for autofill)'); | |
console.log(' Open item in 1Password to copy values when needed'); | |
} catch (err) { | |
console.log('ℹ️ Could not create 1Password item'); | |
console.log(` Try: op --account "${OP_ACCOUNT}" signin`); | |
} | |
} | |
} catch (error) { | |
console.log('ℹ️ 1Password CLI not configured'); | |
} | |
} | |
// Interactive menu | |
console.log('\n' + '='.repeat(60)); | |
console.log('📋 Next Steps Menu'); | |
console.log('='.repeat(60)); | |
const npmUrl = `https://www.npmjs.com/package/${packageName}/access`; | |
console.log('\n[Enter] Open NPM Trusted Publisher settings in browser'); | |
console.log(` → ${npmUrl}`); | |
if (has1PasswordCLI()) { | |
console.log(' (Settings saved in 1Password)'); | |
} | |
console.log('\n[S] Show setup summary'); | |
console.log('[C] Create first release PR'); | |
console.log('[Q] Quit'); | |
console.log('\n👉 Press a key to continue...'); | |
process.stdin.setRawMode(true); | |
process.stdin.resume(); | |
process.stdin.setEncoding('utf8'); | |
const showSummary = () => { | |
console.log('\n' + '='.repeat(60)); | |
console.log('📝 Setup Summary'); | |
console.log('='.repeat(60)); | |
console.log('\n✅ Created files:'); | |
console.log(` • ${createReleasePrPath}`); | |
console.log(` • ${releasePath}`); | |
console.log('\n📦 Package info:'); | |
console.log(` • Name: ${packageName}`); | |
console.log(` • Manager: ${packageManager}`); | |
console.log('\n🔧 NPM Trusted Publisher config:'); | |
console.log(` • Repository: ${repoOwner || '<owner>'}/${repoName || '<repo>'}`); | |
console.log(' • Workflow: release.yml'); | |
console.log('\n⚠️ TODO:'); | |
console.log(' 1. Configure NPM Trusted Publisher'); | |
console.log(' 2. Remove NODE_AUTH_TOKEN from secrets (if exists)'); | |
console.log(' 3. Push changes to remote repository'); | |
}; | |
process.stdin.on('data', async (key) => { | |
const keyStr = key.toString(); | |
if (keyStr === '\r' || keyStr === '\n') { // Enter key | |
console.log('\n🌐 Opening NPM settings in browser...'); | |
try { | |
const opener = process.platform === 'darwin' ? 'open' : | |
process.platform === 'win32' ? 'start' : 'xdg-open'; | |
execSync(`${opener} "${npmUrl}"`); | |
console.log('✅ Browser opened!'); | |
console.log('\n📝 Configure the following in NPM:'); | |
console.log(` • Repository owner: ${repoOwner || '<your-github-username>'}`); | |
console.log(` • Repository name: ${repoName || '<your-repo-name>'}`); | |
console.log(' • Workflow name: release.yml'); | |
console.log(' • Environment: (leave empty)'); | |
if (has1PasswordCLI()) { | |
console.log('\n💡 TIP: Check 1Password for saved configuration'); | |
} | |
console.log('\n✨ Once configured, push changes to remote repository'); | |
console.log(' Then run: gh workflow run create-release-pr'); | |
process.exit(0); | |
} catch (error) { | |
console.log(`\n⚠️ Could not open browser. Please visit:\n ${npmUrl}`); | |
console.log('\n👉 Press a key to continue...'); | |
} | |
} else if (keyStr === 's' || keyStr === 'S') { | |
showSummary(); | |
console.log('\n👉 Press a key to continue...'); | |
} else if (keyStr === 'c' || keyStr === 'C') { | |
console.log('\n🚀 Creating first release PR...'); | |
console.log('\n⚠️ First, ensure you have:'); | |
console.log(' 1. Configured NPM Trusted Publisher'); | |
console.log(' 2. Pushed changes to remote repository'); | |
console.log('\nThen run:'); | |
console.log(' gh workflow run create-release-pr'); | |
process.exit(0); | |
} else if (keyStr === 'q' || keyStr === 'Q' || keyStr === '\u0003') { // q or Ctrl+C | |
console.log('\n✅ Setup complete! Next steps:'); | |
console.log(' 1. Configure NPM Trusted Publisher'); | |
console.log(' 2. Push changes to remote repository'); | |
console.log(' 3. Run: gh workflow run create-release-pr'); | |
process.exit(0); | |
} | |
}); | |
} | |
// Run the setup | |
setupReleaseWorkflow().catch(error => { | |
console.error('❌ Error:', error.message); | |
process.exit(1); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment