Skip to content

Commit 8f56ce8

Browse files
committed
feat: add Salesforce release workflow and interactive release script
1 parent 227943e commit 8f56ce8

7 files changed

Lines changed: 492 additions & 57 deletions

File tree

.github/workflows/sfdx-release.yml

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
name: Salesforce Release
2+
3+
# Creates a new Salesforce managed package version, optionally promotes it to
4+
# released, tags `salesforce-vX.Y.Z`, and cuts a GitHub release.
5+
# Triggered locally via `pnpm release:salesforce`.
6+
on:
7+
workflow_dispatch:
8+
inputs:
9+
bump:
10+
description: "Version bump type"
11+
required: true
12+
default: "patch"
13+
type: choice
14+
options: [patch, minor, major]
15+
16+
promote:
17+
description: "Promote to released (installable in production orgs). False = beta only."
18+
required: true
19+
type: boolean
20+
21+
concurrency:
22+
group: sfdx-release
23+
cancel-in-progress: false
24+
25+
permissions:
26+
contents: write
27+
28+
jobs:
29+
release-salesforce:
30+
runs-on: ubuntu-latest
31+
timeout-minutes: 120
32+
environment: production
33+
34+
steps:
35+
- name: Validate branch
36+
env:
37+
REF_NAME: ${{ github.ref_name }}
38+
run: |
39+
if [[ "$REF_NAME" != "main" && ! "$REF_NAME" =~ ^hotfix/ ]]; then
40+
echo "Error: This workflow can only run on 'main' or a 'hotfix/*' branch. Current branch: $REF_NAME"
41+
exit 1
42+
fi
43+
44+
- uses: actions/checkout@v6
45+
with:
46+
fetch-depth: 0
47+
# Overridden later with a GitHub App installation token so the bump
48+
# commit + tag push are made as the App (on the ruleset bypass list).
49+
persist-credentials: false
50+
51+
- uses: actions/setup-node@v6
52+
with:
53+
node-version: "24"
54+
55+
- name: Install Salesforce CLI
56+
run: npm install -g @salesforce/cli
57+
58+
- name: Decode JWT key
59+
run: echo "${{ secrets.SFDX_JWT_KEY_BASE64 }}" | base64 --decode > server.key
60+
61+
- name: Authenticate to Dev Hub
62+
run: |
63+
sf org login jwt \
64+
--client-id ${{ secrets.SFDX_CONSUMER_KEY }} \
65+
--jwt-key-file server.key \
66+
--username ${{ secrets.SFDX_DEVHUB_USERNAME }} \
67+
--set-default-dev-hub \
68+
--alias devhub
69+
70+
# Generate a GitHub App installation token for git pushes.
71+
# The App's bot account is on the main-branch ruleset bypass list, which a PAT is not.
72+
- name: Generate GitHub App token
73+
id: app-token
74+
uses: actions/create-github-app-token@v3
75+
with:
76+
client-id: ${{ secrets.CLIENT_ID }}
77+
private-key: ${{ secrets.APP_PRIVATE_KEY }}
78+
79+
- name: Get GitHub App user ID
80+
id: app-user
81+
env:
82+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
83+
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
84+
run: |
85+
USER_ID=$(gh api "/users/${APP_SLUG}[bot]" --jq .id)
86+
echo "user-id=${USER_ID}" >> "$GITHUB_OUTPUT"
87+
88+
- name: Configure git for release
89+
env:
90+
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
91+
USER_ID: ${{ steps.app-user.outputs.user-id }}
92+
APP_TOKEN: ${{ steps.app-token.outputs.token }}
93+
REPO: ${{ github.repository }}
94+
run: |
95+
git config user.name "${APP_SLUG}[bot]"
96+
git config user.email "${USER_ID}+${APP_SLUG}[bot]@users.noreply.github.com"
97+
git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${REPO}.git"
98+
99+
- name: Create package version and release
100+
env:
101+
BUMP: ${{ inputs.bump }}
102+
PROMOTE: ${{ inputs.promote }}
103+
SFDX_INSTALLATION_PASSWORD: ${{ secrets.SFDX_INSTALLATION_PASSWORD }}
104+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
105+
REPO: ${{ github.repository }}
106+
run: node apps-sfdx/scripts/sfdx/release.mjs
107+
108+
- name: Remove key file
109+
if: always()
110+
run: rm -f server.key

apps-sfdx/README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,7 @@ apps-sfdx/
8181

8282
## Permissions
8383

84-
Access to the canvas app is gated by the **Jetstream** permission set. The External Client App's OAuth policy pre-authorizes this
85-
permission set (`AdminApprovedPreAuthorized`), so assigning it both grants UI access and authorizes the OAuth connection:
84+
Access to the canvas app is gated by the **Jetstream** permission set.
8685

8786
```bash
8887
sf org assign permset --name Jetstream --target-org <org-alias>

apps-sfdx/scripts/sfdx/release.mjs

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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+
}

apps/jetstream-canvas/src/controllers/preferences.canvas.controller.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ const FIELD = {
1818

1919
const ALL_FIELDS = Object.values(FIELD).join(', ');
2020

21+
const COLOR_SCHEMES = ['light', 'dark', 'system'] as const;
22+
2123
const PreferencesBodySchema = z.object({
2224
preferences: z.object({
2325
skipFrontdoorLogin: z.boolean().optional(),
2426
recordSyncEnabled: z.boolean().optional(),
25-
colorScheme: z.enum(['light', 'dark', 'system']).optional(),
27+
colorScheme: z.enum(COLOR_SCHEMES).optional(),
2628
soqlQueryFormatOptions: SoqlQueryFormatOptionsSchema.optional(),
2729
}),
2830
});
@@ -48,12 +50,17 @@ interface CustomSettingRecord {
4850
[key: string]: unknown;
4951
}
5052

53+
/** The custom setting field is free text, so guard against unexpected values (manual edits, legacy data) */
54+
function toColorScheme(value: unknown): ColorScheme | undefined {
55+
return COLOR_SCHEMES.includes(value as ColorScheme) ? (value as ColorScheme) : undefined;
56+
}
57+
5158
/** Maps a Salesforce custom setting record to our app preferences shape */
5259
function recordToPreferences(record: CustomSettingRecord) {
5360
return {
5461
skipFrontdoorLogin: record[FIELD.skipFrontdoorLogin] as boolean | undefined,
5562
recordSyncEnabled: record[FIELD.recordSyncEnabled] as boolean | undefined,
56-
colorScheme: (record[FIELD.colorScheme] as ColorScheme | undefined) || undefined,
63+
colorScheme: toColorScheme(record[FIELD.colorScheme]),
5764
soqlQueryFormatOptions: {
5865
numIndent: record[FIELD.numIndent] as number | undefined,
5966
fieldMaxLineLength: record[FIELD.fieldMaxLineLength] as number | undefined,

0 commit comments

Comments
 (0)