Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 238 additions & 29 deletions .github/workflows/publish-package-to-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,41 @@ name: publish-package-to-npm
on:
workflow_dispatch:
inputs:
package:
description: Package to be published
type: string
required: true
version:
description: Version to be published
type: string
required: true
code-analyzer-core:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we want each package to have its own checkbox, they all need to be hardcoded here. This is the only file that requires package names to be hardcoded, so it will have to change (slightly) as we add more packages.

description: Should the code-analyzer-core package be released?
type: boolean
required: false
default: false
code-analyzer-engine-api:
description: Should the code-analyzer-engine-api package be released?
type: boolean
required: false
default: false
code-analyzer-eslint-engine:
description: Should the code-analyzer-eslint-engine package be released?
type: boolean
required: false
default: false
code-analyzer-flowtest-engine:
description: Should the code-analyzer-flowtest-engine package be released?
type: boolean
required: false
default: false
code-analyzer-pmd-engine:
description: Should the code-analyzer-pmd-engine package be released?
type: boolean
required: false
default: false
code-analyzer-regex-engine:
description: Should the code-analyzer-regex-engine package be released?
type: boolean
required: false
default: false
code-analyzer-retirejs-engine:
description: Should the code-analyzer-retirejs-engine package be released?
type: boolean
required: false
default: false
dryrun:
description: Add --dry-run to npm publish step? (Uncheck to actually publish)
type: boolean
Expand All @@ -21,33 +48,215 @@ defaults:
shell: bash

jobs:
verify-and-publish:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/${{inputs.package}}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK - super important... we can't lose this. We need to make sure that we are publishing one package at a time from within the package and not at the mono-repo level. This ensures that the package has its dependencies set correctly.

That is, when dealing with monorepos... all packages all share the same node_modules folder. So theoretically 1 package could depend on module A... and another package also depends on module A but forgets to depend on it and the tests pass if we build/test at the monorepo level because the first package brought it in.

So we need to build/test/package in isolation... which means we need to act as if we are not in a monorepo - by making the workspace (pwd) equal to the package directory individually.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stephen-carter-at-sf , Alright, so I could iterate over all the packages in the repo, cd into the folder, and then run npm install/run build/run test from there, and that would work, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes that should work.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stephen-carter-at-sf , done; see line 227.

validate-packages-as-releasable:
runs-on: macos-latest
outputs:
packages-to-release: ${{ steps.main.outputs.packages_to_release }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Verify we are using the correct package.json file
node-version: 'lts/*'
- name: Verify to-be-released packages are SNAPSHOT-versioned
id: main
run: |
PACKAGES_TO_CHECK_ARR=()
# ENGINE API GETS CHECKED FIRST, BECAUSE IT MUST PUBLISH FIRST
if [ "${{ inputs.code-analyzer-engine-api }}" == "true" ]; then
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check for each of the packages is very verbose. Can you have the package names in an array and just loop through them instead? For each of the array element, you could do something like the below:
PACKAGES_TO_CHECK+=("code-analyzer-${PACKAGE}").
This would greatly reduce the code size.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be doable. I think I can also do something similar at line 113. I'll give it a shot and see how it goes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jag-j , I've done some digging, and it looks like I have to reference the inputs to the Github Action as hardcoded strings (e.g., I have to do ${{ inputs.code-analyzer-core }}, and can't do something like ${{ inputs['code-analyzer-' + 'core'] }}.
So I can't get rid of the if-chain in the first step, but I might be able to get rid of the other ones by looping over the PACKAGES_TO_CHECK array in subsequent steps. I'll try that and let you know how it goes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I wasn't able to get rid of it here, but I was able to get rid of it at all of the later steps except for the GraphQL query construction (I really don't want to mess with that unless I have to)

PACKAGES_TO_CHECK_ARR+=('code-analyzer-engine-api')
fi
if [ "${{ inputs.code-analyzer-core }}" == "true" ]; then
PACKAGES_TO_CHECK_ARR+=('code-analyzer-core')
fi
if [ "${{ inputs.code-analyzer-eslint-engine }}" == "true" ]; then
PACKAGES_TO_CHECK_ARR+=('code-analyzer-eslint-engine')
fi
if [ "${{ inputs.code-analyzer-flowtest-engine }}" == "true" ]; then
PACKAGES_TO_CHECK_ARR+=('code-analyzer-flowtest-engine')
fi
if [ "${{ inputs.code-analyzer-pmd-engine }}" == "true" ]; then
PACKAGES_TO_CHECK_ARR+=('code-analyzer-pmd-engine')
fi
if [ "${{ inputs.code-analyzer-regex-engine }}" == "true" ]; then
PACKAGES_TO_CHECK_ARR+=('code-analyzer-regex-engine')
fi
if [ "${{ inputs.code-analyzer-retirejs-engine }}" == "true" ]; then
PACKAGES_TO_CHECK_ARR+=('code-analyzer-retirejs-engine')
fi
PACKAGES_TO_CHECK_STR=$(IFS=' '; echo "${PACKAGES_TO_CHECK_ARR[*]}")
node ./.github/workflows/publish-package-to-npm/validate-packages-as-releasable.js "$PACKAGES_TO_CHECK_STR"
echo "packages_to_release=$PACKAGES_TO_CHECK_STR" >> "$GITHUB_OUTPUT"
prepare-release-branch:
runs-on: macos-latest
env:
GH_TOKEN: ${{ github.token }}
permissions:
contents: write
needs: validate-packages-as-releasable
outputs:
branch-name: ${{ steps.create-release-branch.outputs.branch_name }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Create release branch
id: create-release-branch
run: |
NOW_TIMESTAMP=$(date +%s)
git checkout -b release/$NOW_TIMESTAMP
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sfdx-scanner uses release/vX.Y.Z, but we can't do that here since there's no single version to describe the state of the ecosystem. As such, a Unix timestamp seems like a reasonable way to make sure that each branch has a unique name, and that the branches can be sorted from oldest to newest if needed.

# Immediately push the branch with no changes, so GraphQL can push to it later.
git push --set-upstream origin release/$NOW_TIMESTAMP
# Output the branch name so that it can be used later.
echo "branch_name=release/$NOW_TIMESTAMP" >> "$GITHUB_OUTPUT"
- name: Strip '-SNAPSHOT' from to-be-released package versions
run: |
[[ -f package.json ]] || (echo "::error:: ./packages/${{inputs.package}}/package.json does not exist." && exit 1)
PACKAGE_VERSION=`cat package.json | jq '.version' | xargs`
[[ ${{ inputs.version }} == ${PACKAGE_VERSION} ]] || (echo "::error:: Input version ${{ inputs.version }} does not match package.json version ${PACKAGE_VERSION}" && exit 1)
PACKAGE_NAME=`cat package.json | jq '.name' | xargs`
[[ "@salesforce/${{ inputs.package }}" == ${PACKAGE_NAME} ]] || (echo "::error:: Input package "@salesforce/${{ inputs.package }}" does not match package.json name ${PACKAGE_NAME}" && exit 1)
- name: Build and test
PACKAGE_LIST=(${{ needs.validate-packages-as-releasable.outputs.packages-to-release }})
for PACKAGE_NAME in "${PACKAGE_LIST[@]}"
do
cd ./packages/${PACKAGE_NAME}
npm --no-git-tag-version version patch # Increments X.Y.Z-SNAPSHOT to X.Y.Z, which is what we want.
cd ../..
done
- name: Update inter-package dependencies
run: |
RELEASABLE_PACKAGES=${{ needs.validate-packages-as-releasable.outputs.packages-to-release }}
cd packages
ALL_PACKAGES=`ls`
cd ..
node ./.github/workflows/publish-package-to-npm/update-dependencies-on-released-pacakges.js "$RELEASABLE_PACKAGES" "$ALL_PACKAGES"
- name: Build
run: |
npm install
npm run build
npm run test
- name: publish-to-npm
# No need to test; that comes later.
- name: Commit changes to release branch
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
if [ "${{inputs.dryrun}}" == "true" ]; then
npm publish --tag latest-alpha --access public --verbose --dry-run
else
npm publish --tag latest-alpha --access public --verbose
fi
# GraphQL needs to know what branch to push to.
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
# GraphQL needs a message for the commit.
MESSAGE="Preparing Core Ecosystem for release"
# GraphQL needs the latest versions of all the package.json files, as Base64 encoded strings.
CORE_PACKAGE_JSON="$(cat packages/code-analyzer-core/package.json | base64)"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had no desire to try dynamically generating the GraphQL query, so I just added one variable per package JSON (meaning that new packages will require new variables), and add them all to the commit. Adding them all regardless of whether they changed should be fine, because an unchanged file would just be a no-op (according to my understanding).

API_PACKAGE_JSON="$(cat packages/code-analyzer-engine-api/package.json | base64)"
ESLINT_PACKAGE_JSON="$(cat packages/code-analyzer-eslint-engine/package.json | base64)"
FLOWTEST_PACKAGE_JSON="$(cat packages/code-analyzer-flowtest-engine/package.json | base64)"
PMD_PACKAGE_JSON="$(cat packages/code-analyzer-pmd-engine/package.json | base64)"
REGEX_PACKAGE_JSON="$(cat packages/code-analyzer-regex-engine/package.json | base64)"
RETIREJS_PACKAGE_JSON="$(cat packages/code-analyzer-retirejs-engine/package.json | base64)"
TEMPLATE_PACKAGE_JSON="$(cat packages/T-E-M-P-L-A-T-E/package.json | base64)"
# GraphQL also needs the top-level package-lock.json
PACKAGE_LOCK_JSON="$(cat package-lock.json | base64)"

gh api graphql -F message="$MESSAGE" -F oldOid=`git rev-parse HEAD` -F branch="$BRANCH" \
-F corePackage="$CORE_PACKAGE_JSON" \
-F apiPackage="$API_PACKAGE_JSON" \
-F eslintPackage="$ESLINT_PACKAGE_JSON" \
-F flowtestPackage="$FLOWTEST_PACKAGE_JSON" \
-F pmdPackage="$PMD_PACKAGE_JSON" \
-F regexPackage="$REGEX_PACKAGE_JSON" \
-F retirejsPackage="$RETIREJS_PACKAGE_JSON" \
-F templatePackage="$TEMPLATE_PACKAGE_JSON" \
-F packageLock="$PACKAGE_LOCK_JSON" \
-f query='
mutation ($message: String!, $oldOid: GitObjectID!, $branch: String!,
$corePackage: Base64String!, $apiPackage: Base64String!, $eslintPackage: Base64String!,
$flowtestPackage: Base64String!, $pmdPackage: Base64String!, $regexPackage: Base64String!,
$retirejsPackage: Base64String!, $templatePackage: Base64String!, $packageLock: Base64String!) {
createCommitOnBranch(input: {
branch: {
repositoryNameWithOwner: "forcedotcom/code-analyzer-core",
branchName: $branch
},
message: {
headline: $message
},
fileChanges: {
additions: [
{
path: "packages/code-analyzer-core/package.json",
contents: $corePackage
}, {
path: "packages/code-analyzer-engine-api/package.json",
contents: $apiPackage
}, {
path: "packages/code-analyzer-eslint-engine/package.json",
contents: $eslintPackage
}, {
path: "packages/code-analyzer-flowtest-engine/package.json",
contents: $flowtestPackage
}, {
path: "packages/code-analyzer-pmd-engine/package.json",
contents: $pmdPackage
}, {
path: "packages/code-analyzer-regex-engine/package.json",
contents: $regexPackage
}, {
path: "packages/code-analyzer-retirejs-engine/package.json",
contents: $retirejsPackage
}, {
path: "packages/T-E-M-P-L-A-T-E/package.json",
contents: $templatePackage
}, {
path: "package-lock.json",
contents: $packageLock
}
},
expectedHeadOid: $oldOid
}) {
commit {
id
}
}
}'
build-and-test-and-publish:
runs-on: ubuntu-latest
needs: [validate-packages-as-releasable, prepare-release-branch]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare-release-branch.outputs.branch-name }}
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Build and test and publish packages
run: |
PACKAGES_TO_PUBLISH=(${{ needs.validate-packages-as-releasable.outputs.packages-to-release }})
for PACKAGE_NAME in "${PACKAGES_TO_PUBLISH[@]}"
do
cd ./packages/${PACKAGE_NAME}
# Build and test each package individually instead of at the mono-repo level, to validate shared dependencies.
npm install
npm run build
npm run test
# We need the NPM token to publish.
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
PUBLISHED_VERSION=$(jq -r ".version" package.json)
if [ "${{ inputs.dryrun }}" == "true" ]]; then
npm publish --tag latest-alpha --access public --verbose --dry-run
echo "Fake-published ${PACKAGE_NAME}@${PUBLISHED_VERSION}"
else
npm publish --tag latest-alpha --access public --verbose
echo "Published ${PACKAGE_NAME}@${PUBLISHED_VERSION}"
fi
cd ../..
done
create-postrelease-pull-request:
runs-on: macos-latest
needs: [prepare-release-branch, build-and-test-and-publish]
if: ${{ inputs.dryrun == 'false' }} # A Dry Run doesn't release, so no PR should be made
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare-release-branch.outputs.branch-name }}
- run: |
echo -e "This branch and PR were automatically created as part of a Package Publish.\n\
The branch increments the .version property of published packages from X.Y.Z-SNAPSHOT to X.Y.Z, and updates any\
inter-package dependencies appropriately.\n\
The narrow scope of these changes makes a merge conflict unlikely, but if one does occur, you should consult\
with the author of the conflicting change and decide what to do next. Ultimately it may make sense to not merge\
this pull request at all. Use your judgment." > body.txt
gh pr create -B dev -H ${{ needs.prepare-release-branch.outputs.branch-name }} --title "POSTRELEASE @W-XXXXXXXX@ Merging after ecosystem release" -F body.txt
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No hardcoded references to particular packages, so this requires no changes as we add more.

Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
const path = require('path');
const fs = require('fs');

function main() {
const packagesToRelease = process.argv[2].split(" ");
const allPackages = process.argv[3].split("\n");

const packageJsonsToRelease = getPackageJsons(packagesToRelease);
const allPackageJsons = getPackageJsons(allPackages);

const packageChangesMade = updatePackageDependenciesAndDescribeChanges(packageJsonsToRelease, allPackageJsons);
if (packageChangesMade.size > 0) {
displayMapOfLists('THE FOLLOWING DEPENDENCY CHANGES WERE MADE CHANGES WERE MADE:', packageChangesMade);
persistPackageJsons(allPackages, allPackageJsons);
} else {
console.log('NO PACKAGE.JSON CHANGES WERE MADE');
}
}

function displayMapOfLists(header, mapOfLists) {
console.log(header);
for (const [key, innerList] of mapOfLists.entries()) {
console.log(`IN ${key}:`);
for (const innerListItem of innerList) {
console.log(`* ${innerListItem}`);
}
console.log('');
}
console.log('\n');
}

function getPackageJsons(packageNames) {
return packageNames.map(getPackageJson);
}

function getPackageJson(packageName) {
const packageJsonPath = path.join('packages', packageName, 'package.json');
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
}

function updatePackageDependenciesAndDescribeChanges(packagesToRelease, allPackages) {
const changeDescriptionMap = new Map();
for (const potentialDependency of packagesToRelease) {
const dependencyName = potentialDependency.name;
const dependencyVersion = potentialDependency.version;
for (const potentiallyDependentPackage of allPackages) {
const potentiallyDependentPackageName = potentiallyDependentPackage.name;
const dependedUponVersion = potentiallyDependentPackage.dependencies[dependencyName];
if (dependedUponVersion != null) {
potentiallyDependentPackage.dependencies[dependencyName] = dependencyVersion;
const changeDescriptionArray = changeDescriptionMap.get(potentiallyDependentPackageName) || [];
changeDescriptionArray.push(`${dependencyName}@${dependedUponVersion} -> ${dependencyName}@${dependencyVersion}`);
changeDescriptionMap.set(potentiallyDependentPackageName, changeDescriptionArray);
}
}
}
return changeDescriptionMap;
}

function persistPackageJsons(packageNames, packageJsons) {
packageNames.forEach((packageName, idx) => {
const packageJson = packageJsons[idx];
persistPackageJson(packageName, packageJson);
});
}

function persistPackageJson(packageName, packageJson) {
const packageJsonPath = path.join('packages', packageName, 'package.json');
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.stringify(..., 2) preserves the indent styling of the existing package.json files, meaning that only the value of .version changes. If any new ones deviate from that indenting, then this will result in the entire file appearing to change (though that change will be almost entirely whitespace).

}

main();
Loading
Loading