projenrc/release.ts (333 lines of code) (raw):
import { github, typescript } from 'projen';
import { ACTIONS_CHECKOUT, ACTIONS_SETUP_NODE, YARN_INSTALL } from './common';
export const enum PublishTargetOutput {
DIST_TAG = 'dist-tag',
GITHUB_RELEASE = 'github-release',
IS_LATEST = 'latest',
IS_PRERELEASE = 'prerelease',
}
// The ARN of the OpenPGP key used to sign release artifacts uploaded to GH Releases.
const CODE_SIGNING_USER_ID = 'aws-jsii@amazon.com';
export class ReleaseWorkflow {
public constructor(private readonly project: typescript.TypeScriptProject) {
new ReleaseTask(project);
new TagReleaseTask(project);
let release = project.github!.addWorkflow('release');
release.runName = 'Release ${{ github.ref_name }}';
release.on({ push: { tags: ['v*.*.*'] } });
const nodeVersion = project.minNodeVersion?.split('.', 1).at(0) ?? 'lts/*';
const releasePackageName = 'release-package';
const publishTarget = 'publish-target';
const federateToAwsStep: github.workflows.JobStep = {
name: 'Federate to AWS',
uses: 'aws-actions/configure-aws-credentials@v1',
with: {
'aws-region': 'us-east-1',
'role-to-assume': '${{ secrets.AWS_ROLE_TO_ASSUME }}',
'role-session-name': 'GHA-aws-jsii-compiler@${{ github.ref_name }}',
},
};
release.addJob('build', {
name: 'Build release package',
env: {
CI: 'true',
},
outputs: {
[PublishTargetOutput.DIST_TAG]: { stepId: publishTarget, outputName: PublishTargetOutput.DIST_TAG },
[PublishTargetOutput.IS_LATEST]: { stepId: publishTarget, outputName: PublishTargetOutput.IS_LATEST },
[PublishTargetOutput.GITHUB_RELEASE]: { stepId: publishTarget, outputName: PublishTargetOutput.GITHUB_RELEASE },
[PublishTargetOutput.IS_PRERELEASE]: { stepId: publishTarget, outputName: PublishTargetOutput.IS_PRERELEASE },
},
permissions: {
idToken: github.workflows.JobPermission.WRITE,
contents: github.workflows.JobPermission.READ,
},
runsOn: ['ubuntu-latest'],
steps: [
ACTIONS_CHECKOUT(),
ACTIONS_SETUP_NODE(nodeVersion),
YARN_INSTALL(),
{
name: 'Prepare Release',
run: 'yarn release ${{ github.ref_name }}',
},
{
name: 'Determine Target',
id: publishTarget,
run: 'yarn ts-node projenrc/publish-target.ts ${{ github.ref_name }}',
env: {
// A GitHub token is required to list GitHub Releases, so we can tell if the `latest` dist-tag is needed.
GITHUB_TOKEN: '${{ github.token }}',
},
},
{
...federateToAwsStep,
// Only necessary if we're going to be publishing assets to GitHub Releases.
if: `fromJSON(steps.publish-target.outputs.${PublishTargetOutput.GITHUB_RELEASE})`,
},
{
name: 'Sign Tarball',
if: `fromJSON(steps.publish-target.outputs.${PublishTargetOutput.GITHUB_RELEASE})`,
run: [
'set -eo pipefail',
// First, we're going to be configuring GPG "correctly"
'export GNUPGHOME=$(mktemp -d)',
'echo "charset utf-8" > ${GNUPGHOME}/gpg.conf',
'echo "no-comments" >> ${GNUPGHOME}/gpg.conf',
'echo "no-emit-version" >> ${GNUPGHOME}/gpg.conf',
'echo "no-greeting" >> ${GNUPGHOME}/gpg.conf',
// Now, we need to import the OpenPGP private key into the keystore
'secret=$(aws secretsmanager get-secret-value --secret-id=${{ secrets.OPEN_PGP_KEY_ARN }} --query=SecretString --output=text)',
'privatekey=$(node -p "(${secret}).PrivateKey")',
'passphrase=$(node -p "(${secret}).Passphrase")',
'echo "::add-mask::${passphrase}"', // !!! IMPORTANT !!! (Ensures the value does not leak into public logs)
'unset secret',
'echo ${passphrase} | gpg --batch --yes --import --armor --passphrase-fd=0 <(echo "${privatekey}")',
'unset privatekey',
// Now we can actually detach-sign the artifacts
'for file in $(find dist -type f -not -iname "*.asc"); do',
` echo \${passphrase} | gpg --batch --yes --local-user=${JSON.stringify(
CODE_SIGNING_USER_ID,
)} --detach-sign --armor --pinentry-mode=loopback --passphrase-fd=0 \${file}`,
'done',
'unset passphrase',
// Clean up the GnuPG home directory (secure-wipe)
'find ${GNUPGHOME} -type f -exec shred --remove {} \\;',
].join('\n'),
},
{
name: 'Upload artifact',
uses: 'actions/upload-artifact@v4.3.6',
with: {
name: releasePackageName,
path: '${{ github.workspace }}/dist',
overwrite: true,
},
},
],
});
const downloadArtifactStep: github.workflows.JobStep = {
name: 'Download artifact',
uses: 'actions/download-artifact@v4',
with: {
name: releasePackageName,
},
};
release.addJob('release-to-github', {
name: 'Create GitHub Release',
env: {
CI: 'true',
},
if: `fromJSON(needs.build.outputs.${PublishTargetOutput.GITHUB_RELEASE})`,
needs: ['build'],
permissions: {
contents: github.workflows.JobPermission.WRITE,
},
runsOn: ['ubuntu-latest'],
steps: [
downloadArtifactStep,
{
id: 'release-exists',
name: 'Verify if release exists',
run: [
'if gh release view ${{ github.ref_name }} --repo=${{ github.repository }} &>/dev/null',
'then',
'echo "result=true" >> $GITHUB_OUTPUT',
'else',
'echo "result=false" >> $GITHUB_OUTPUT',
'fi',
].join('\n'),
env: {
GH_TOKEN: '${{ github.token }}',
},
},
{
name: 'Create PreRelease',
if: `!fromJSON(steps.release-exists.outputs.result) && fromJSON(needs.build.outputs.${PublishTargetOutput.IS_PRERELEASE})`,
run: [
'gh release create ${{ github.ref_name }}',
'--repo=${{ github.repository }}',
'--generate-notes',
'--title=${{ github.ref_name }}',
'--verify-tag',
'--prerelease',
`--latest=\${{ needs.build.outputs.${PublishTargetOutput.IS_LATEST} }}`,
].join(' '),
env: {
GH_TOKEN: '${{ github.token }}',
},
},
{
name: 'Create Release',
if: `!fromJSON(steps.release-exists.outputs.result) && !fromJSON(needs.build.outputs.${PublishTargetOutput.IS_PRERELEASE})`,
run: [
'gh release create ${{ github.ref_name }}',
'--repo=${{ github.repository }}',
'--generate-notes',
'--title=${{ github.ref_name }}',
'--verify-tag',
`--latest=\${{ needs.build.outputs.${PublishTargetOutput.IS_LATEST} }}`,
].join(' '),
env: {
GH_TOKEN: '${{ github.token }}',
},
},
{
name: 'Attach assets',
run: [
'gh release upload ${{ github.ref_name }}',
'--repo=${{ github.repository }}',
'--clobber',
'${{ github.workspace }}/**/*',
].join(' '),
env: {
GH_TOKEN: '${{ github.token }}',
},
},
],
});
release.addJob('release-npm-package', {
name: `Release to registry.npmjs.org`,
env: {
CI: 'true',
},
needs: ['build'],
permissions: {
idToken: github.workflows.JobPermission.WRITE,
contents: github.workflows.JobPermission.READ,
},
runsOn: ['ubuntu-latest'],
steps: [
downloadArtifactStep,
{
...ACTIONS_SETUP_NODE(),
with: {
'always-auth': true,
'node-version': nodeVersion,
'registry-url': `https://registry.npmjs.org/`,
},
},
federateToAwsStep,
{
name: 'Set NODE_AUTH_TOKEN',
run: [
'secret=$(aws secretsmanager get-secret-value --secret-id=${{ secrets.NPM_TOKEN_ARN }} --query=SecretString --output=text)',
'token=$(node -p "(${secret}).token")',
'unset secret',
'echo "::add-mask::${token}"', // !!! IMPORTANT !!! (Ensures the value does not leak into public logs)
'echo "NODE_AUTH_TOKEN=${token}" >> $GITHUB_ENV',
'unset token',
].join('\n'),
},
{
name: 'Publish',
run: [
'npm publish ${{ github.workspace }}/js/jsii-*.tgz',
'--access=public',
`--tag=\${{ needs.build.outputs.${PublishTargetOutput.DIST_TAG} }}`,
].join(' '),
},
{
name: 'Tag "latest"',
if: `fromJSON(needs.build.outputs.${PublishTargetOutput.IS_LATEST})`,
run: 'npm dist-tag add jsii@${{ github.ref_name }} latest',
},
],
});
}
public autoTag(opts: AutoTagWorkflowProps): this {
const suffix = opts.nameSuffix ? `-${opts.nameSuffix}` : opts.branch ? `-${opts.branch}` : '';
new AutoTagWorkflow(this.project, `auto-tag-${opts.preReleaseId ?? 'releases'}${suffix}`, opts);
return this;
}
}
class ReleaseTask {
public constructor(project: typescript.TypeScriptProject) {
const task = project.addTask('release', {
description: 'Prepare a release bundle',
});
task.exec('ts-node projenrc/set-version.ts', {
name: 'set-version',
receiveArgs: true,
});
task.spawn(project.preCompileTask);
task.spawn(project.compileTask);
task.spawn(project.postCompileTask);
task.spawn(project.testTask);
task.spawn(project.packageTask);
task.exec('yarn version --no-git-tag-version --new-version 0.0.0', {
name: 'reset-version',
});
}
}
class TagReleaseTask {
public constructor(project: typescript.TypeScriptProject) {
const task = project.addTask('tag-release', {
description: 'Tag this commit for release',
});
task.exec('ts-node projenrc/tag-release.ts', {
name: 'tag-release',
receiveArgs: true,
});
}
}
interface AutoTagWorkflowProps {
/**
* The version used as the tagging base
*/
readonly releaseLine: string;
/**
* The branch on which to trigger this AutoTagWorkflow.
*
* @default - the repository's default branch
*/
readonly branch?: string;
/**
* The schedule on which to run this AutoTagWorkflow instance.
*
* @see https://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html#tag_20_25_07
*
* @default none
*/
readonly schedule?: string;
/**
* The run name to use for this workflow.
*
* @default - GitHub's default run name will be used.
*/
readonly runName?: string;
/**
* The pre-release identifier to be used.
*
* @default - a regular release will be tagged.
*/
readonly preReleaseId?: string;
/**
* The workflow name suffix. A single `-` will be prepended to this value if
* present, which is then appended at the end of the workflow name.
*
* @default - derived from the branch name (if present).
*/
readonly nameSuffix?: string;
}
class AutoTagWorkflow {
public constructor(project: typescript.TypeScriptProject, name: string, props: AutoTagWorkflowProps) {
const nodeVersion = project.minNodeVersion?.split('.', 1).at(0) ?? 'lts/*';
const workflow = project.github!.addWorkflow(name);
workflow.runName = props.runName;
workflow.on({
schedule:
props.schedule != null
? [
{
cron: props.schedule,
},
]
: undefined,
workflowDispatch: {},
});
workflow.addJob('pre-flight', {
name: 'Pre-Flight Checks',
runsOn: ['ubuntu-latest'],
outputs: {
sha: {
stepId: 'git',
outputName: 'sha',
},
},
permissions: { contents: github.workflows.JobPermission.READ },
steps: [
ACTIONS_CHECKOUT(props.branch),
ACTIONS_SETUP_NODE(nodeVersion),
YARN_INSTALL(),
{ name: 'Build', run: 'yarn build' },
{ id: 'git', name: 'Identify git SHA', run: 'echo sha=$(git rev-parse HEAD) >> $GITHUB_OUTPUT' },
],
});
workflow.addJob('auto-tag', {
name: 'Auto-Tag Release',
needs: ['pre-flight'],
runsOn: ['ubuntu-latest'],
permissions: {},
steps: [
ACTIONS_CHECKOUT('${{ needs.pre-flight.outputs.sha }}', { token: '${{ secrets.PROJEN_GITHUB_TOKEN }}' }),
ACTIONS_SETUP_NODE(nodeVersion),
YARN_INSTALL(),
{
name: 'Set git identity',
run: ['git config user.name "github-actions"', 'git config user.email "github-actions@github.com"'].join(
'\n',
),
},
{
name: `Tag ${props.preReleaseId ? 'PreRelease' : 'Release'}`,
run: `yarn tag-release --idempotent --no-sign --push ${
props.preReleaseId ? `--prerelease=${props.preReleaseId} ` : ''
}--release-line=${props.releaseLine}`,
},
],
});
}
}