|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +/** |
| 4 | + * In-workflow Salesforce managed package release worker. |
| 5 | + * |
| 6 | + * Runs inside the `sfdx-release.yml` GitHub Action after the Dev Hub auth and |
| 7 | + * git identity have been configured. It: |
| 8 | + * 1. Bumps `versionName` / `versionNumber` in sfdx-project.json |
| 9 | + * 2. Creates a new package version (`sf package version create`) |
| 10 | + * 3. Optionally promotes it to "released" |
| 11 | + * 4. Commits the bump, tags `salesforce-vX.Y.Z`, and cuts a GitHub release |
| 12 | + * 5. Writes a rich job summary including the install URL |
| 13 | + * |
| 14 | + * Uses only Node built-ins so CI does not need to install apps-sfdx dependencies. |
| 15 | + * |
| 16 | + * Required env: BUMP (patch|minor|major), PROMOTE (true|false), SFDX_INSTALLATION_PASSWORD. |
| 17 | + * Optional env: REPO, GITHUB_STEP_SUMMARY, DRY_RUN (true skips all sf/git/gh side effects). |
| 18 | + */ |
| 19 | + |
| 20 | +import { execFileSync } from 'node:child_process'; |
| 21 | +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; |
| 22 | +import path from 'node:path'; |
| 23 | +import { fileURLToPath } from 'node:url'; |
| 24 | + |
| 25 | +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); |
| 26 | +// apps-sfdx root – sf commands must run here so they can locate sfdx-project.json |
| 27 | +const sfdxProjectDir = path.resolve(scriptDir, '../..'); |
| 28 | +const sfdxProjectFile = path.join(sfdxProjectDir, 'sfdx-project.json'); |
| 29 | + |
| 30 | +const PACKAGE_NAME = 'Jetstream for Salesforce'; |
| 31 | +const TAG_PREFIX = 'salesforce-v'; |
| 32 | + |
| 33 | +const bump = requireEnv('BUMP'); |
| 34 | +const promote = process.env.PROMOTE === 'true'; |
| 35 | +const isDryRun = process.env.DRY_RUN === 'true'; |
| 36 | +const installationKey = isDryRun ? 'dry-run-key' : requireEnv('SFDX_INSTALLATION_PASSWORD'); |
| 37 | +const repo = process.env.REPO ?? 'jetstreamapp/jetstream'; |
| 38 | + |
| 39 | +if (!['patch', 'minor', 'major'].includes(bump)) { |
| 40 | + fail(`Invalid BUMP "${bump}". Expected one of: patch, minor, major.`); |
| 41 | +} |
| 42 | + |
| 43 | +// ── 1. Bump version in sfdx-project.json ──────────────────────────────────── |
| 44 | +const projectJson = JSON.parse(readFileSync(sfdxProjectFile, 'utf8')); |
| 45 | +const defaultPackage = projectJson.packageDirectories?.find(({ package: pkg }) => pkg === PACKAGE_NAME); |
| 46 | +if (!defaultPackage) { |
| 47 | + fail(`Could not find package "${PACKAGE_NAME}" in sfdx-project.json.`); |
| 48 | +} |
| 49 | + |
| 50 | +const version = nextVersion(defaultPackage.versionNumber, bump); |
| 51 | +const versionString = `${version.major}.${version.minor}.${version.patch}`; |
| 52 | +const tag = `${TAG_PREFIX}${versionString}`; |
| 53 | + |
| 54 | +console.log(`Releasing ${PACKAGE_NAME} ${versionString} (${bump} bump from ${defaultPackage.versionNumber})`); |
| 55 | +console.log(`Promote to released: ${promote ? 'yes' : 'no (beta)'}`); |
| 56 | + |
| 57 | +defaultPackage.versionName = `v${versionString}`; |
| 58 | +defaultPackage.versionNumber = `${versionString}.NEXT`; |
| 59 | +writeFileSync(sfdxProjectFile, `${JSON.stringify(projectJson, null, 2)}\n`); |
| 60 | + |
| 61 | +// ── 2. Create the package version ─────────────────────────────────────────── |
| 62 | +// `--json` keeps output parseable; `--code-coverage` is required to later promote. |
| 63 | +console.log('\nCreating package version (this can take several minutes)...'); |
| 64 | +const createResult = isDryRun |
| 65 | + ? { result: { SubscriberPackageVersionId: '04tXXXXXXXXXXXXXXX' } } |
| 66 | + : JSON.parse( |
| 67 | + captureSf([ |
| 68 | + 'package', |
| 69 | + 'version', |
| 70 | + 'create', |
| 71 | + '--package', |
| 72 | + PACKAGE_NAME, |
| 73 | + '--installation-key', |
| 74 | + installationKey, |
| 75 | + '--code-coverage', |
| 76 | + '--wait', |
| 77 | + '90', |
| 78 | + '--json' |
| 79 | + ]) |
| 80 | + ); |
| 81 | + |
| 82 | +const subscriberPackageVersionId = createResult?.result?.SubscriberPackageVersionId; |
| 83 | +if (!subscriberPackageVersionId) { |
| 84 | + fail(`Package version create did not return a SubscriberPackageVersionId.\n${JSON.stringify(createResult, null, 2)}`); |
| 85 | +} |
| 86 | +console.log(`Created package version: ${subscriberPackageVersionId}`); |
| 87 | + |
| 88 | +// ── 3. Promote (optional) ─────────────────────────────────────────────────── |
| 89 | +if (promote && !isDryRun) { |
| 90 | + console.log('\nPromoting package version to released...'); |
| 91 | + captureSf(['package', 'version', 'promote', '--package', subscriberPackageVersionId, '--no-prompt', '--json']); |
| 92 | + console.log('Promoted.'); |
| 93 | +} |
| 94 | + |
| 95 | +// ── 4. Commit, tag, push, GitHub release ──────────────────────────────────── |
| 96 | +if (!isDryRun) { |
| 97 | + console.log('\nCommitting version bump and creating tag...'); |
| 98 | + git(['add', 'sfdx-project.json']); |
| 99 | + git(['commit', '-m', `chore: release salesforce ${tag.replace(TAG_PREFIX, 'v')}`]); |
| 100 | + git(['tag', tag]); |
| 101 | + git(['push', 'origin', 'HEAD']); |
| 102 | + git(['push', 'origin', tag]); |
| 103 | + |
| 104 | + console.log('Creating GitHub release...'); |
| 105 | + exec('gh', ['release', 'create', tag, '--title', `Jetstream for Salesforce ${versionString}`, '--notes', releaseNotes()], { |
| 106 | + cwd: sfdxProjectDir |
| 107 | + }); |
| 108 | +} |
| 109 | + |
| 110 | +// ── 5. Job summary ────────────────────────────────────────────────────────── |
| 111 | +writeSummary(); |
| 112 | +console.log('\nDone.'); |
| 113 | + |
| 114 | +// ── Helpers ───────────────────────────────────────────────────────────────── |
| 115 | + |
| 116 | +function installUrl() { |
| 117 | + return `https://login.salesforce.com/packaging/installPackage.apexp?p0=${subscriberPackageVersionId}`; |
| 118 | +} |
| 119 | + |
| 120 | +function releaseNotes() { |
| 121 | + return [ |
| 122 | + `**Version:** ${versionString}`, |
| 123 | + `**Status:** ${promote ? 'Released (installable in production orgs)' : 'Beta (scratch/sandbox only)'}`, |
| 124 | + `**Package version id:** \`${subscriberPackageVersionId}\``, |
| 125 | + '', |
| 126 | + `**Install:** ${installUrl()}`, |
| 127 | + '', |
| 128 | + 'An installation key is required to install this package (see the `SFDX_INSTALLATION_PASSWORD` secret).' |
| 129 | + ].join('\n'); |
| 130 | +} |
| 131 | + |
| 132 | +function writeSummary() { |
| 133 | + const lines = [ |
| 134 | + `## 🚀 Salesforce Managed Package Release`, |
| 135 | + '', |
| 136 | + `| Field | Value |`, |
| 137 | + `|-------|-------|`, |
| 138 | + `| Version | \`${versionString}\` |`, |
| 139 | + `| Bump | \`${bump}\` |`, |
| 140 | + `| Status | ${promote ? '✅ Released' : '🧪 Beta'} |`, |
| 141 | + `| Tag | [${tag}](https://github.com/${repo}/releases/tag/${tag}) |`, |
| 142 | + `| Package version id | \`${subscriberPackageVersionId}\` |`, |
| 143 | + '', |
| 144 | + `**Install URL:** ${installUrl()}`, |
| 145 | + '', |
| 146 | + '> An installation key is required to install (the `SFDX_INSTALLATION_PASSWORD` value).', |
| 147 | + '' |
| 148 | + ]; |
| 149 | + const summary = lines.join('\n'); |
| 150 | + console.log(`\n${summary}`); |
| 151 | + if (process.env.GITHUB_STEP_SUMMARY) { |
| 152 | + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); |
| 153 | + } |
| 154 | +} |
| 155 | + |
| 156 | +/** Parse `X.Y.Z.NEXT` (or `X.Y.Z`) and return the bumped components. */ |
| 157 | +function nextVersion(versionNumber, bumpType) { |
| 158 | + const [major, minor, patch] = versionNumber.split('.').map((part) => Number.parseInt(part, 10)); |
| 159 | + if ([major, minor, patch].some((part) => Number.isNaN(part))) { |
| 160 | + fail(`Could not parse versionNumber "${versionNumber}". Expected "X.Y.Z.NEXT".`); |
| 161 | + } |
| 162 | + switch (bumpType) { |
| 163 | + case 'major': |
| 164 | + return { major: major + 1, minor: 0, patch: 0 }; |
| 165 | + case 'minor': |
| 166 | + return { major, minor: minor + 1, patch: 0 }; |
| 167 | + default: |
| 168 | + return { major, minor, patch: patch + 1 }; |
| 169 | + } |
| 170 | +} |
| 171 | + |
| 172 | +function git(args) { |
| 173 | + exec('git', args, { cwd: sfdxProjectDir }); |
| 174 | +} |
| 175 | + |
| 176 | +/** Run an `sf` command and return its stdout (used for `--json` output). */ |
| 177 | +function captureSf(args) { |
| 178 | + try { |
| 179 | + return execFileSync('sf', args, { |
| 180 | + cwd: sfdxProjectDir, |
| 181 | + encoding: 'utf8', |
| 182 | + stdio: ['inherit', 'pipe', 'inherit'], |
| 183 | + maxBuffer: 64 * 1024 * 1024 |
| 184 | + }); |
| 185 | + } catch (error) { |
| 186 | + // sf emits the JSON error payload on stdout even on failure |
| 187 | + const details = error.stdout || error.message; |
| 188 | + fail(`sf ${args[0]} ${args[1]} failed:\n${details}`); |
| 189 | + } |
| 190 | +} |
| 191 | + |
| 192 | +function exec(command, args, options = {}) { |
| 193 | + execFileSync(command, args, { stdio: 'inherit', maxBuffer: 64 * 1024 * 1024, ...options }); |
| 194 | +} |
| 195 | + |
| 196 | +function requireEnv(name) { |
| 197 | + const value = process.env[name]; |
| 198 | + if (!value) { |
| 199 | + fail(`Missing required environment variable: ${name}`); |
| 200 | + } |
| 201 | + return value; |
| 202 | +} |
| 203 | + |
| 204 | +function fail(message) { |
| 205 | + console.error(`\nError: ${message}`); |
| 206 | + process.exit(1); |
| 207 | +} |
0 commit comments