Skip to content

Instantly share code, notes, and snippets.

@azu
Created September 7, 2025 04:33
Show Gist options
  • Save azu/2864f536002c6e99f9c57a379b093f40 to your computer and use it in GitHub Desktop.
Save azu/2864f536002c6e99f9c57a379b093f40 to your computer and use it in GitHub Desktop.
setup script GitHub and Npm Trusted Publish
#!/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