From 7ddd6081eebc10a22a1bac215c5e66da07bf85ef Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Fri, 23 Feb 2024 00:51:48 -0800 Subject: [PATCH] chore: additional release automation (#9231) * chore: add additional automated release capabilities * "publish: stash of uncommitted changes by release script" * beta script * remove emtpy file * update beta publishing * "publish: stash of uncommitted changes by release script" * update scripts * update documentation --- .github/workflows/beta-release.yml | 47 -- .github/workflows/release/promote-lts.yml | 54 +++ .github/workflows/release/publish-beta.yml | 93 ++++ .../publish-canary.yml} | 12 +- .github/workflows/release/publish-lts.yml | 62 +++ .github/workflows/release/publish-stable.yml | 100 ++++ CHANGELOG.md | 2 +- RELEASE.md | 241 ++-------- package.json | 4 +- pnpm-lock.yaml | 10 - .../core/publish/steps/confirm-strategy.ts | 24 - .../core/publish/steps/generate-strategy.ts | 136 ------ publish/core/utils/package.ts | 68 --- publish/index.ts | 39 -- publish/strategy.json | 35 -- publish/utils/flags-config.ts | 197 -------- release/core/promote/index.ts | 136 ++++++ .../core/publish/index.ts | 27 +- .../core/publish/steps/bump-versions.ts | 4 +- .../core/publish/steps/confirm-strategy.ts | 41 ++ .../core/publish/steps/generate-strategy.ts | 130 ++++++ .../core/publish/steps/generate-tarballs.ts | 2 +- .../core/publish/steps/print-strategy.ts | 6 +- .../core/publish/steps/publish-packages.ts | 4 +- release/core/release-notes/index.ts | 57 +++ .../release-notes/steps/confirm-changelogs.ts | 43 ++ .../core/release-notes/steps/get-changes.ts | 161 +++++++ .../steps/submit-pr-to-branch.ts | 0 .../release-notes/steps/update-changelogs.ts | 132 ++++++ .../core/utils/next-version.ts | 0 release/guide/canary.md | 26 ++ {publish => release}/help/-utils.ts | 0 {publish => release}/help/docs.ts | 48 +- {publish => release}/help/sections/about.ts | 0 {publish => release}/help/sections/manual.ts | 0 release/index.ts | 67 +++ release/strategy.json | 56 +++ {publish => release}/tsconfig.json | 0 {publish => release}/utils/channel.ts | 0 {publish => release}/utils/cmd.ts | 3 +- release/utils/flags-config.ts | 434 ++++++++++++++++++ {publish => release}/utils/git.ts | 64 ++- {publish => release}/utils/json-file.ts | 0 release/utils/package.ts | 130 ++++++ {publish => release}/utils/parse-args.ts | 2 +- {publish => release}/utils/write.ts | 0 46 files changed, 1892 insertions(+), 805 deletions(-) delete mode 100644 .github/workflows/beta-release.yml create mode 100644 .github/workflows/release/promote-lts.yml create mode 100644 .github/workflows/release/publish-beta.yml rename .github/workflows/{alpha-release.yml => release/publish-canary.yml} (80%) create mode 100644 .github/workflows/release/publish-lts.yml create mode 100644 .github/workflows/release/publish-stable.yml delete mode 100644 publish/core/publish/steps/confirm-strategy.ts delete mode 100644 publish/core/publish/steps/generate-strategy.ts delete mode 100644 publish/core/utils/package.ts delete mode 100755 publish/index.ts delete mode 100644 publish/strategy.json delete mode 100644 publish/utils/flags-config.ts create mode 100644 release/core/promote/index.ts rename publish/core/publish.ts => release/core/publish/index.ts (66%) rename {publish => release}/core/publish/steps/bump-versions.ts (95%) create mode 100644 release/core/publish/steps/confirm-strategy.ts create mode 100644 release/core/publish/steps/generate-strategy.ts rename {publish => release}/core/publish/steps/generate-tarballs.ts (99%) rename {publish => release}/core/publish/steps/print-strategy.ts (93%) rename {publish => release}/core/publish/steps/publish-packages.ts (94%) create mode 100644 release/core/release-notes/index.ts create mode 100644 release/core/release-notes/steps/confirm-changelogs.ts create mode 100644 release/core/release-notes/steps/get-changes.ts create mode 100644 release/core/release-notes/steps/submit-pr-to-branch.ts create mode 100644 release/core/release-notes/steps/update-changelogs.ts rename {publish => release}/core/utils/next-version.ts (100%) create mode 100644 release/guide/canary.md rename {publish => release}/help/-utils.ts (100%) rename {publish => release}/help/docs.ts (58%) rename {publish => release}/help/sections/about.ts (100%) rename {publish => release}/help/sections/manual.ts (100%) create mode 100755 release/index.ts create mode 100644 release/strategy.json rename {publish => release}/tsconfig.json (100%) rename {publish => release}/utils/channel.ts (100%) rename {publish => release}/utils/cmd.ts (98%) create mode 100644 release/utils/flags-config.ts rename {publish => release}/utils/git.ts (65%) rename {publish => release}/utils/json-file.ts (100%) create mode 100644 release/utils/package.ts rename {publish => release}/utils/parse-args.ts (99%) rename {publish => release}/utils/write.ts (100%) diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml deleted file mode 100644 index 435594825a6..00000000000 --- a/.github/workflows/beta-release.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Canary-Mirror-Beta Release - -on: - workflow_dispatch: - -env: - TURBO_API: http://127.0.0.1:9080 - TURBO_TOKEN: this-is-not-a-secret - TURBO_TEAM: myself - -jobs: - release: - name: Run publish script - runs-on: ubuntu-latest - environment: deployment - steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 - with: - fetch-depth: 3 - ref: beta - - run: git fetch origin main --depth=1 - - name: Get last beta version from package.json - uses: sergeysova/jq-action@v2 - id: version - with: - cmd: 'jq .version package.json -r' - - name: Reset the Beta Branch - run: git reset --hard origin/main && git push origin beta -f - - uses: ./.github/actions/setup - with: - build-addons: true - install: true - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Make sure git user is setup - run: | - git config --local user.email 'tomster@emberjs.com' - git config --local user.name 'Ember.js Alpha Releaser' - - name: Publish with script - run: node scripts/publish.js beta --skipSmokeTest --fromVersion=${{ steps.version.outputs.value }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} - - name: Push branch + tag - run: git push origin HEAD --follow-tags - - uses: actions/upload-artifact@v4 - with: - name: tarballs - path: ember-data-*.tgz diff --git a/.github/workflows/release/promote-lts.yml b/.github/workflows/release/promote-lts.yml new file mode 100644 index 00000000000..667fa50e78f --- /dev/null +++ b/.github/workflows/release/promote-lts.yml @@ -0,0 +1,54 @@ +name: Promote LTS Release + +on: + workflow_dispatch: + inputs: + version: + description: 'The existing version to promote (e.g. `4.0.0`)' + type: string + channel: + description: 'The NPM Distribution Tag (e.g. `lts` or `lts-4-8`)' + type: string + update-branch: + description: 'Whether to update the associated LTS branch to the same commit as the tag' + default: true + type: boolean + +env: + TURBO_API: http://127.0.0.1:9080 + TURBO_TOKEN: this-is-not-a-secret + TURBO_TEAM: myself + +jobs: + release: + name: Run Release Script + runs-on: ubuntu-latest + environment: deployment + steps: + - name: Enforce Branch + # Note: we always checkout main in actions/checkout, but this enforces + # good hygiene. + if: github.ref != 'refs/heads/main' + run: | + echo "Releases may only be performed from the main branch." + exit 1 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + with: + fetch-depth: 1 + fetch-tags: true + progress: false + token: ${{ secrets.GH_DEPLOY_TOKEN }} + - uses: ./.github/actions/setup + with: + install: true + repo-token: ${{ secrets.GH_DEPLOY_TOKEN }} + - name: Make sure git user is setup + run: | + git config --local user.email ${{ secrets.GH_DEPLOY_EMAIL }} + git config --local user.name ${{ secrets.GH_DEPLOY_NAME }} + - name: Publish with script + run: bun release exec promote --v=${{ github.event.inputs.version }} --t=${{ github.event.inputs.channel }} -u ${{ github.event.inputs.update-branch }} + env: + FORCE_COLOR: 2 + CI: true + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} diff --git a/.github/workflows/release/publish-beta.yml b/.github/workflows/release/publish-beta.yml new file mode 100644 index 00000000000..33b3a2c658b --- /dev/null +++ b/.github/workflows/release/publish-beta.yml @@ -0,0 +1,93 @@ +name: Publish Beta Release + +on: + workflow_dispatch: + inputs: + # This input is used to determine whether to start/continue a beta-cycle vs mirror from canary. + # + # A beta-cycle "forks" from canary. It starts by updating the beta branch to the current state + # of main (canary). Thereafter any updates to the beta branch are cherry-picked from main or PR'd + # to the beta branch. + # + # The (default) mirror approach instead directly copies the canary release to the beta branch + # each time. This is useful when the changes in canary are relatively minor or safe to release + # and + # and then publishing a beta release. A mirror is a direct copy of the canary release. + kind: + description: 'Whether to start/continue a beta-cycle vs mirror from canary' + required: true + default: 'mirror' + type: choice + options: + - beta-cycle # start or continue a beta-cycle. + - mirror # mirror code from canary. This is the default. + # At cycle start we must always reset the beta branch to main. + is-cycle-start: + description: 'Whether this is the start of a new release cycle (either kind)' + required: true + default: false + type: boolean + +env: + TURBO_API: http://127.0.0.1:9080 + TURBO_TOKEN: this-is-not-a-secret + TURBO_TEAM: myself + +jobs: + release: + name: Run publish script + runs-on: ubuntu-latest + environment: deployment + steps: + - name: Enforce Branch + # Note: we always checkout beta in actions/checkout, but this enforces + # good hygiene. + if: github.ref != 'refs/heads/main' + run: | + echo "Releases may only be performed from the main branch." + exit 1 + - name: Make sure git user is setup + run: | + git config --local user.email ${{ secrets.GH_DEPLOY_EMAIL }} + git config --local user.name ${{ secrets.GH_DEPLOY_NAME }} + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + with: + fetch-tags: true + progress: false + token: ${{ secrets.GH_DEPLOY_TOKEN }} + fetch-depth: 3 + ref: beta + - run: git fetch origin main --depth=1 + - name: Get last beta version from package.json + if: github.event.inputs.kind == 'mirror' + uses: sergeysova/jq-action@v2 + id: version + with: + cmd: 'jq .version package.json -r' + - name: Reset the Beta Branch + if: github.event.inputs.kind == 'mirror' || github.event.inputs.is-cycle-start == 'true' + run: git reset --hard origin/main && git push origin beta -f + - uses: ./.github/actions/setup + with: + install: true + repo-token: ${{ secrets.GH_DEPLOY_TOKEN }} + - name: Publish New Release + # For beta-cycle we always increment from the branch state + # For mirror we increment from the last beta version, unless it's start of a new cycle. + if: github.event.inputs.kind == 'beta-cycle' || github.event.inputs.is-cycle-start == 'true' + run: bun release exec publish beta + env: + FORCE_COLOR: 2 + CI: true + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + - name: Publish New Mirror Release + if: github.event.inputs.kind == 'mirror' + run: bun release exec publish beta --from=${{ steps.version.outputs.stdout }} + env: + FORCE_COLOR: 2 + CI: true + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + - uses: actions/upload-artifact@v4 + with: + name: tarballs + path: tmp/tarballs/**/*.tgz diff --git a/.github/workflows/alpha-release.yml b/.github/workflows/release/publish-canary.yml similarity index 80% rename from .github/workflows/alpha-release.yml rename to .github/workflows/release/publish-canary.yml index 971c2057182..15af94a925f 100644 --- a/.github/workflows/alpha-release.yml +++ b/.github/workflows/release/publish-canary.yml @@ -1,4 +1,4 @@ -name: Alpha Releases +name: Publish Canary Release on: workflow_dispatch: @@ -27,12 +27,20 @@ jobs: runs-on: ubuntu-latest environment: deployment steps: + - name: Enforce Branch + # Note: we always checkout main in actions/checkout, but this enforces + # good hygiene. + if: github.ref != 'refs/heads/main' + run: | + echo "Releases may only be performed from the main branch." + exit 1 - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 with: fetch-depth: 1 fetch-tags: true progress: false token: ${{ secrets.GH_DEPLOY_TOKEN }} + ref: main - name: Check should run if HEAD is untagged run: | echo "HEAD is $(git name-rev --tags --name-only $(git rev-parse HEAD))" @@ -48,7 +56,7 @@ jobs: git config --local user.email ${{ secrets.GH_DEPLOY_EMAIL }} git config --local user.name ${{ secrets.GH_DEPLOY_NAME }} - name: Publish with script - run: bun run publish canary -i ${{ github.event.inputs.increment }} + run: bun release canary -i ${{ github.event.inputs.increment }} env: FORCE_COLOR: 2 CI: true diff --git a/.github/workflows/release/publish-lts.yml b/.github/workflows/release/publish-lts.yml new file mode 100644 index 00000000000..b5d20987f2e --- /dev/null +++ b/.github/workflows/release/publish-lts.yml @@ -0,0 +1,62 @@ +name: Publish LTS Release + +on: + workflow_dispatch: + inputs: + branch: + description: 'The branch to publish from, e.g. `lts-4-12`' + required: true + type: string + channel: + description: 'The NPM Distribution Tag. `lts` for current lts. `lts-prev` for e.g. `lts-4-8`' + type: option + default: 'lts' + required: true + options: + - lts + - lts-prev + +env: + TURBO_API: http://127.0.0.1:9080 + TURBO_TOKEN: this-is-not-a-secret + TURBO_TEAM: myself + +jobs: + release: + name: Run publish script + runs-on: ubuntu-latest + environment: deployment + steps: + - name: Enforce Branch + # Note: we always checkout the correct lts branch in actions/checkout, but this enforces + # good hygiene. + if: github.ref != 'refs/heads/main' + run: | + echo "Releases may only be performed from the main branch." + exit 1 + - name: Make sure git user is setup + run: | + git config --local user.email ${{ secrets.GH_DEPLOY_EMAIL }} + git config --local user.name ${{ secrets.GH_DEPLOY_NAME }} + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + with: + fetch-tags: true + progress: false + token: ${{ secrets.GH_DEPLOY_TOKEN }} + fetch-depth: 3 + ref: ${{ github.event.inputs.source-branch }} + - uses: ./.github/actions/setup + with: + install: true + repo-token: ${{ secrets.GH_DEPLOY_TOKEN }} + - name: Publish New LTS Release + # We always increment from the branch state + run: bun release publish ${{ github.event.inputs.channel }} + env: + FORCE_COLOR: 2 + CI: true + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + - uses: actions/upload-artifact@v4 + with: + name: tarballs + path: tmp/tarballs/**/*.tgz diff --git a/.github/workflows/release/publish-stable.yml b/.github/workflows/release/publish-stable.yml new file mode 100644 index 00000000000..45977bcf883 --- /dev/null +++ b/.github/workflows/release/publish-stable.yml @@ -0,0 +1,100 @@ +name: Publish Stable Release + +on: + workflow_dispatch: + inputs: + source-branch: + description: 'If starting a new cycle, or reversioning, the source branch to update the release branch from' + required: false + default: 'beta' + type: choice + options: + - beta # promotes beta to stable + - main # promotes canary to stable + - release # re-releases a stable version + # At cycle start we must always reset the release branch to beta. + is-cycle-start: + description: 'Whether this is the start of a new release cycle' + required: true + default: false + type: boolean + # downversion e.g. 5.4.0-alpha.1 => 5.3.1 happens when we use a canary, beta or later release to hotfix a stable + # upversion e.g. 5.3.1 => 5.4.0 happens when we re-release an existing stable as a new minor/major + # examples: + # Upversion: 5.3.1 => 5.4.0 + # from-version: 5.3.1 + # increment: minor + # Downversion: 5.4.0-alpha.1 => 5.3.1 + # from-version: 5.3.0 + # increment: patch + from-version: + description: 'When upversioning or downversioning, the version from which to increment to get the version number for the release' + type: string + increment: + description: 'Type of Version Bump To Perform (only used when upversioning or downversioning)' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + +env: + TURBO_API: http://127.0.0.1:9080 + TURBO_TOKEN: this-is-not-a-secret + TURBO_TEAM: myself + +jobs: + release: + name: Perform Release + runs-on: ubuntu-latest + environment: deployment + steps: + - name: Enforce Branch + # Note: we always checkout release in actions/checkout, but this enforces + # good hygiene. + if: github.ref != 'refs/heads/main' + run: | + echo "Releases may only be performed from the main branch." + exit 1 + - name: Make sure git user is setup + run: | + git config --local user.email ${{ secrets.GH_DEPLOY_EMAIL }} + git config --local user.name ${{ secrets.GH_DEPLOY_NAME }} + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + with: + fetch-tags: true + progress: false + token: ${{ secrets.GH_DEPLOY_TOKEN }} + fetch-depth: 3 + ref: release + ## Ensure we have a copy of the source branch + - run: git fetch origin ${{ github.event.inputs.source-branch }} --depth=1 + - name: Reset the Release Branch + if: github.event.inputs.source-branch != 'release' && (github.event.inputs.is-cycle-start == 'true' || github.event.inputs.from-version != null) + run: git reset --hard origin/${{ github.event.inputs.source-branch }} && git push origin release -f + - uses: ./.github/actions/setup + with: + install: true + repo-token: ${{ secrets.GH_DEPLOY_TOKEN }} + - name: Publish New Release + # If we are not reversioning + # Then we do the default patch increment from the current branch state. + # This handles both start-of-cycle and bugfix releases. + if: github.event.inputs.from-version == null + run: bun release publish release + env: + FORCE_COLOR: 2 + CI: true + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + - name: Publish New Release (Reversion) + # If we are reversioning + # Then we increment from the branch with the supplied increment + # This handles both upversioning and downversioning + if: github.event.inputs.from-version != null + run: bun release publish release --from=${{ github.event.inputs.from-version }} --increment=${{ github.event.inputs.increment }} + env: + FORCE_COLOR: 2 + CI: true + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 35830c015f3..9dfbf03e08c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Ember Data Changelog +# EmberData Changelog ## v5.3.0 (2023-09-18) diff --git a/RELEASE.md b/RELEASE.md index 987f654be40..e1797d9d98f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,15 +1,12 @@ # Release -The EmberData release process has a few manual steps. +The EmberData release process is mostly automated but requires manually configuring +and triggering the appropriate workflow. -The following steps navigate us through some of the release gotchas and will -hopefully result in a successful release. +There are four standard and two non-standard release channels -There are four release channels, `lts`, `release`, `beta` and `canary`. -Each has it's own section below. - -In this guide, we are assuming that the remote `origin` is `git@github.com:emberjs/data.git`, -this remote needs to exist and `origin/main` `origin/beta` `origin/release` etc. need to be the upstreams of the local `main` `beta` `release` branches etc. +- standard releases: `lts`, `release`, `beta`, `canary`. +- non-standard releases: `lts-prev` `release-prev` ## Before We Start @@ -20,233 +17,57 @@ all recent planning discussions and work is properly accounted for. ## Getting Setup To Do A Release -In order to release `ember-data` you must first ensure the following things: +In order to release EmberData you must have commit rights to `ember-data` on GITHUB. +Everything else is handled by automation. + +In the event you do need to perform a manuall release, you must also have permission +to push to protected branches, and access tokens for npm and github with permissions +to the related package scopes. For more information about manual releases run +`bun release about` in the repository. + +For manually releases you will need to ensure at least the following: - You have `commit` rights to `ember-data` on GitHub -- You have an account on `npm` and belongs to the `ember-data` organization on NPM -- You have `publish` rights within the `ember-data` organization on NPM +- You have an account on `npm` and belongs to the `ember-data` and `warp-drive` organizations on NPM +- You have `publish` rights within the `ember-data` and `warp-drive` organizations on NPM - You have configured your NPM account to use `2fa` (two factor authentication) - You have logged into your NPM account on your machine (typically sessions preserve nearly forever once you have) - You have configured `GITHUB_AUTH` token for `lerna-changelog` to be able to gather info for the release notes. - You have installed `bun`, `pnpm` and `node` globally (or better, via `volta`) +- the remote `origin` is `git@github.com:emberjs/data.git`, +-`origin/main` `origin/beta` `origin/release` etc. need to be the upstreams of the local `main` `beta` `release` branches etc. ## Release Order -When releasing more than one channel, we release from "most stable" to "least stable" +When releasing more than one channel, we release from "most stable" to "least stable". +This is what allows changes to flow down from canary to lts versioned seamlessly. - `lts` (_Most Stable_) - `release` - `beta` - `canary` (_Least Stable_) -## Announce release! - -Once you have finished this release process, we recommend posting an announcement to -Twitter the Crosslinking the announcement to the following Discord channels. - -- [#news-and-announcements](https://discordapp.com/channels/480462759797063690/480499624663056390) -- [#dev-ember-data](https://discordapp.com/channels/480462759797063690/480501977931972608) -- [#ember-data](https://discordapp.com/channels/480462759797063690/486549196837486592) - -### LTS Release - -1. Checkout the correct branch - - a. For the first release of a new `LTS`, create a new branch from `origin/release` - - DO THIS PRIOR TO PUBLISHING THE NEXT RELEASE - - ``` - git fetch origin; - git checkout -b lts-- origin/release; - ``` - - b. For subsequent releases of this `LTS`, ensure your local branch is in-sync with the remote. - - ``` - git fetch origin; - git checkout lts--; - git reset --hard origin/lts--; - ``` - -2. Generate the Changelog - -> Note: If this is the first release of the LTS and there are no changes, just add an entry for the next patch version stating we are promoting the release to LTS. - -The Changelog is generated with [lerna-changelog](https://github.com/lerna/lerna-changelog). - -The changelog is generated based on labels applied to PRs since the last release. These labels are configured in the root `package.json`. Before merging PRs reviewers should always ensure a meaningful title for the changelog exists. +Since non-standard releases are always bespoke, they do not participate in the above flow. -For the first release of an LTS, `previous-version` will be the last released version of the `release` channel: e.g. `v4.8.1` +You will find the automated workflows to perform these releases under the actions tab on github. -For subsequent versions it will be whatever version number we previously published for this LTS. +## Polish the Release! -To actually generate the changelog, run: - -``` -pnpm exec lerna-changelog --from=PREVIOUS_VERSION_TAG -``` - -Note: if it is the first time that you use lerna-changelog, you might have to add a token to fetch from Github API: -https://github.com/lerna/lerna-changelog#github-token - -Then: - -- insert lerna-changelog output to `CHANGELOG.md` underneath the document title -- commit the changelog and push the change upstream: - -``` -git add CHANGELOG.md; -git commit -m "Update Changelog for v" -git push origin lts-- // Note: alternatively, you can make a PR to lts-- to make sure there are no errors -``` - -3. Publish the LTS - - ``` - bun run publish lts - ``` - -4. Update the Release Notes on Github - -- Visit [Ember Data Releases](https://github.com/emberjs/data/releases) - - Click on the "Tags" - - Click on the tag just published - - Edit the tag, adding a meaningful title and attaching the changelog (see other releases for examples) - - Publish the release! - -### Latest / Stable Release - -1. Checkout the `release` branch and ensure it is in-sync with `origin/release`. - - DO NOT WORK FROM A LOCAL `release` branch THAT DIFFERS - - a. If this is the first `release` release of the cycle, we "cut" from `beta`. - - DO THIS PRIOR TO PUBLISHING THE NEXT BETA - - ``` - git checkout release; - git fetch origin; - git reset --hard origin/beta; - git push origin release -f; - ``` - - b. For subsequent `release` releases during the cycle, we release from the `release` branch. - - ``` - git checkout release; - git fetch origin; - git reset --hard origin/release; - ``` - -2. Generate the Changelog - - IT IS IMPORTANT THAT ALL CHANGES ARE ON THE REMOTE BRANCH SPECIFIED BY HEAD - - `previous-version` will be whatever version we previously published as a `release`. E.g. if our last release was `4.8.4` and now we are publishing `4.9.0` then we would use `--from=v4.8.4` - - ``` - pnpm exec lerna-changelog --from=PREVIOUS_VERSION_TAG - ``` - -- prepend a new section title for this version with Today's date to `CHANGELOG.md` -- insert changelog script output to `CHANGELOG.md` underneath this new section title -- edit changelog output to be as user-friendly as possible (drop [INTERNAL] changes, non-code changes, etc.) -- commit the changelog and push the change upstream - - ``` - git add CHANGELOG.md; - git commit -m "Update Changelog for v"; - git push origin release; - ``` - -5. Publish the release - - ``` - bun run publish release - ``` - -6. Update the Release Notes on Github +First, update the Release Notes on Github - Visit [Ember Data Releases](https://github.com/emberjs/data/releases) - Click on the "more recent tags" - Click on the tag just published - Edit the tag, adding a meaningful title and attaching the changelog (see other releases for examples) - Publish the release! + - Only set the release as latest if it should be the `latest` tag on npm as well (e.g. the `release` channel). LTS/Beta/Canary/LTS-prev/Release-prev should never be marked as `latest`. -### Manual Beta Releases - -> Note: Most Beta Releases should be handled by the `Canary-Mirror-Beta Release` workflow, which should be manually triggered from the actions page. - -1. Checkout the `#beta` branch and ensure it is in-sync with `origin/beta`. - - DO NOT WORK FROM A LOCAL `beta` branch THAT DIFFERS - - a. If this is the first `beta` release of the cycle, we "cut" from `#main`. - - DO THIS PRIOR TO PUBLISHING THE NEXT CANARY - - ``` - git checkout beta; - git fetch origin; - git reset --hard origin/main; - git push origin beta -f; - ``` - - b. For subsequent `beta` releases during the cycle, we release from the beta branch. - - ``` - git checkout beta; - git fetch origin; - git reset --hard origin/beta; - ``` +Once you have finished this release process, we recommend posting an announcement to your +Threads/Mastadon/Twitter accounts and the crosslinking the announcement to the following +Discord channels. -2. Publish the weekly beta - - ``` - bun run publish beta - ``` - -### Canary Releases - -1. Checkout the `#main` branch and ensure it is in-sync with `origin/main`. - - DO NOT WORK FROM A LOCAL `main` branch THAT DIFFERS - - ```js - git checkout main; - git fetch origin; - git reset --hard origin/main - ``` - -2. Publish the nightly. - - a. If this is the very first `canary` release for a new minor - - ``` - bun run publish canary -i minor - ``` - - b. If this is the very first `canary` release for a new major - - ``` - bun run publish canary -i major - ``` - - c. For all other "nightly" canary releases - - ``` - bun run publish canary - ``` - -Congrats, you are finished! - -#### Canary Auto Publish - -New canary versions are published to npm every Tuesday and Friday at 12pm PST by the `Alpha Release` GitHub action. They can also be published using the workflow trigger. +- [#news-and-announcements](https://discordapp.com/channels/480462759797063690/480499624663056390) +- [#dev-ember-data](https://discordapp.com/channels/480462759797063690/480501977931972608) +- [#ember-data](https://discordapp.com/channels/480462759797063690/486549196837486592) -It will always increment the pre-release version of what's currently in the root `package.json`. For example from `3.25.0-alpha.1` to `3.25.0-alpha.2`. **It requires a human to manually bump minor and major versions and publish**. -To try out the script that will be executed in the GitHub action, use: -`bun run publish canary --dry_run --dangerously_force`. The `--dry_run` param will skip auto committing the version change and publishing. diff --git a/package.json b/package.json index 47d0751b30e..97e6650b2a7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "scripts": { "takeoff": "FORCE_COLOR=2 pnpm install --reporter=append-only", "prepare": "pnpm build", - "publish": "./publish/index.ts", + "release": "./release/index.ts", "build": "turbo _build --log-order=stream --filter=./packages/* --concurrency=1; pnpm run sync:tests", "sync:tests": "pnpm run --filter=./tests/* -r --workspace-concurrency=1 --if-present _syncPnpm", "build:docs": "mkdir -p packages/-ember-data/dist && cd ./docs-generator && node ./compile-docs.js", @@ -46,8 +46,6 @@ "common-tags": "^1.8.2", "debug": "^4.3.4", "execa": "^8.0.1", - "fromentries": "^1.3.2", - "git-repo-info": "^2.1.1", "git-repo-version": "^1.0.2", "globby": "^14.0.0", "lerna-changelog": "^2.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 769a4f3da42..1afe3a14f39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,12 +60,6 @@ importers: execa: specifier: ^8.0.1 version: 8.0.1 - fromentries: - specifier: ^1.3.2 - version: 1.3.2 - git-repo-info: - specifier: ^2.1.1 - version: 2.1.1 git-repo-version: specifier: ^1.0.2 version: 1.0.2 @@ -12742,10 +12736,6 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} - /fromentries@1.3.2: - resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==} - dev: true - /fs-extra@0.24.0: resolution: {integrity: sha512-w1RvhdLZdU9V3vQdL+RooGlo6b9R9WVoBanOfoJvosWlqSKvrjFlci2oVhwvLwZXBtM7khyPvZ8r3fwsim3o0A==} dependencies: diff --git a/publish/core/publish/steps/confirm-strategy.ts b/publish/core/publish/steps/confirm-strategy.ts deleted file mode 100644 index 8d10a760a9a..00000000000 --- a/publish/core/publish/steps/confirm-strategy.ts +++ /dev/null @@ -1,24 +0,0 @@ -import chalk from 'chalk'; -import * as readline from 'readline/promises'; - -export async function confirmStrategy() { - if (process.env.CI) { - return; - } - const confirm = await question( - chalk.white(`\nDo you want to continue with this strategy? ${chalk.yellow(`[y/n]`)}: `) - ); - const input = confirm.trim().toLowerCase(); - if (input !== 'y' && input !== 'yes') { - console.log(chalk.red('🚫 Strategy not confirmed. Exiting...')); - process.exit(1); - } -} - -export async function question(prompt: string) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return rl.question(prompt); -} diff --git a/publish/core/publish/steps/generate-strategy.ts b/publish/core/publish/steps/generate-strategy.ts deleted file mode 100644 index 0b0cce6c103..00000000000 --- a/publish/core/publish/steps/generate-strategy.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { getFile } from '../../../utils/json-file'; -import { GIT_STATE } from '../../../utils/git'; -import { STRATEGY_TYPE, CHANNEL, npmDistTagForChannelAndVersion, TYPE_STRATEGY } from '../../../utils/channel'; -import { Glob } from 'bun'; -import { APPLIED_STRATEGY, PACKAGEJSON, Package } from '../../utils/package'; -import { getNextVersion } from '../../utils/next-version'; -import path from 'path'; - -const PROJECT_ROOT = process.cwd(); - -export interface STRATEGY { - config: { - packageRoots: string[]; - }; - defaults: { - stage: STRATEGY_TYPE; - types: TYPE_STRATEGY; - }; - rules: Record< - string, - { - stage: STRATEGY_TYPE; - types: TYPE_STRATEGY; - } - >; -} - -function buildGlob(dirPath: string) { - return `${dirPath}/package.json`; -} - -export async function gatherPackages(config: STRATEGY['config']) { - const packages: Map = new Map(); - - // add root - const rootFilePath = `${process.cwd()}/package.json`; - const rootFile = getFile(rootFilePath); - const rootPkgData = await rootFile.read(); - packages.set('root', new Package(rootFilePath, rootFile, rootPkgData)); - - // add other packages - for (const dirPath of config.packageRoots) { - const glob = new Glob(buildGlob(dirPath)); - - // Scans the current working directory and each of its sub-directories recursively - for await (const filePath of glob.scan('.')) { - const file = getFile(filePath); - const pkgData = await file.read(); - packages.set(pkgData.name, new Package(filePath, file, pkgData)); - } - } - - return packages; -} - -export async function loadStrategy() { - const file = getFile(`${PROJECT_ROOT}/publish/strategy.json`); - const data = await file.read(); - return data; -} - -function sortByName(map: Map) { - const sorted = [...map.values()]; - sorted.sort((a, b) => { - if (a.name.startsWith('@') && !b.name.startsWith('@')) { - return 1; - } - - if (!a.name.startsWith('@') && b.name.startsWith('@')) { - return -1; - } - return a.name > b.name ? 1 : -1; - }); - map.clear(); - sorted.forEach((v) => { - map.set(v.name, v); - }); -} - -function getPkgDir(pkgFilePath: string) { - const relative = path.relative(PROJECT_ROOT, pkgFilePath); - const parts = relative.split('/'); - if (parts.length === 1) { - return ''; - } - return '/' + parts[0]; -} - -export async function applyStrategy( - config: Map, - gitInfo: GIT_STATE, - strategy: STRATEGY, - packages: Map -): Promise { - const channel = config.get('channel') as CHANNEL; - const increment = config.get('increment') as 'major' | 'minor' | 'patch'; - const applied_strategies = new Map(); - const private_pkgs = new Map(); - const public_pks = new Map(); - - packages.forEach((pkg, name) => { - const rule = strategy.rules[name] || strategy.defaults; - const applied_strategy = Object.assign({}, rule) as APPLIED_STRATEGY; - - applied_strategy.name = name; - applied_strategy.private = Boolean(pkg.pkgData.private); - applied_strategy.pkgDir = getPkgDir(pkg.filePath); - applied_strategy.fromVersion = pkg.pkgData.version; - applied_strategy.toVersion = getNextVersion(applied_strategy.fromVersion, channel, increment, rule.stage); - - // channels may not change outside of a major or minor bump - // major and minor bumps may only occur on beta|canary|release|lts - // and never lts-* or release-* and so existing fromVersion is safe - // to use. - applied_strategy.distTag = npmDistTagForChannelAndVersion(channel, applied_strategy.fromVersion); - applied_strategies.set(name, applied_strategy); - - applied_strategy.private ? private_pkgs.set(name, applied_strategy) : public_pks.set(name, applied_strategy); - }); - - sortByName(applied_strategies); - sortByName(private_pkgs); - sortByName(public_pks); - - return { - all: applied_strategies, - private_pkgs, - public_pks, - }; -} - -export type AppliedStrategy = { - all: Map; - private_pkgs: Map; - public_pks: Map; -}; diff --git a/publish/core/utils/package.ts b/publish/core/utils/package.ts deleted file mode 100644 index 2b817d071af..00000000000 --- a/publish/core/utils/package.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { JSONFile } from '../../utils/json-file'; -import { NPM_DIST_TAG, SEMVER_VERSION, STRATEGY_TYPE, TYPE_STRATEGY } from '../../utils/channel'; - -export class Package { - declare filePath: string; - declare file: JSONFile; - declare pkgData: PACKAGEJSON; - declare tarballPath: string; - - constructor(filePath: string, file: JSONFile, pkgData: PACKAGEJSON) { - this.filePath = filePath; - this.file = file; - this.pkgData = pkgData; - this.tarballPath = ''; - } - - async refresh() { - await this.file.invalidate(); - this.pkgData = await this.file.read(); - } -} - -/** - * A valid package.json file can go up to 3 levels deep - * when defining the exports field. - * - * ``` - * { - * "exports": { - * ".": "./index.js", - * "main": { - * "import": "./index.js", - * "require": "./index.js" - * "browser": { - * "import": "./index.js", - * "require": "./index.js" - * } - * } - * } - * } - * ``` - * - * @internal - */ -type ExportConfig = Record>>; - -export type PACKAGEJSON = { - name: string; - version: SEMVER_VERSION; - private: boolean; - dependencies?: Record; - devDependencies?: Record; - peerDependencies?: Record; - scripts?: Record; - files?: string[]; - exports?: ExportConfig; -}; - -export type APPLIED_STRATEGY = { - name: string; - private: boolean; - stage: STRATEGY_TYPE; - types: TYPE_STRATEGY; - fromVersion: SEMVER_VERSION; - toVersion: SEMVER_VERSION; - distTag: NPM_DIST_TAG; - pkgDir: string; -}; diff --git a/publish/index.ts b/publish/index.ts deleted file mode 100755 index 304f125df6d..00000000000 --- a/publish/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bun -import chalk from 'chalk'; -import { printHelpDocs } from './help/docs'; -import { normalizeFlag } from './utils/parse-args'; -import { getCommands } from './utils/flags-config'; -import { printAbout } from './help/sections/about'; -import { executePublish } from './core/publish'; -import { write } from './utils/write'; - -const COMMANDS = { - help: printHelpDocs, - about: printAbout, - default: executePublish, -}; - -async function main() { - const args = Bun.argv.slice(2); - - write( - chalk.grey( - `\n\t${chalk.bold( - chalk.greenBright('Warp') + chalk.magentaBright('Drive') - )} | Automated Release\n\t==============================` - ) + chalk.grey(`\n\tengine: ${chalk.cyan('bun@' + Bun.version)}\n`) - ); - - if (args.length === 0) { - args.push('help'); - } - - const commands = getCommands(); - const cmdString = (commands.get(normalizeFlag(args[0])) as keyof typeof COMMANDS) || 'default'; - - const cmd = COMMANDS[cmdString]; - await cmd(args); - process.exit(0); -} - -await main(); diff --git a/publish/strategy.json b/publish/strategy.json deleted file mode 100644 index 882de025b40..00000000000 --- a/publish/strategy.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "config": { - "packageRoots": ["packages/*", "tests/*", "config"] - }, - "defaults": { - "stage": "stable", - "types": "private" - }, - "rules": { - "@warp-drive/holodeck": { - "stage": "alpha", - "types": "private" - }, - "@warp-drive/diagnostic": { - "stage": "alpha", - "types": "private" - }, - "eslint-plugin-ember-data": { - "stage": "alpha", - "types": "private" - }, - "@warp-drive/core-types": { - "stage": "alpha", - "types": "private" - }, - "@warp-drive/schema": { - "stage": "alpha", - "types": "private" - }, - "@warp-drive/schema-record": { - "stage": "alpha", - "types": "private" - } - } -} diff --git a/publish/utils/flags-config.ts b/publish/utils/flags-config.ts deleted file mode 100644 index 8f53de57721..00000000000 --- a/publish/utils/flags-config.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { HELP } from '../help/sections/manual'; -import { ABOUT } from '../help/sections/about'; -import { normalizeFlag, type CommandConfig, type FlagConfig } from './parse-args'; -import { CHANNEL, npmDistTagForChannelAndVersion } from './channel'; -import { getGitState } from './git'; -import chalk from 'chalk'; - -export const flags_config: FlagConfig = { - help: { - name: 'Help', - flag: 'help', - flag_aliases: ['h', 'm'], - flag_mispellings: [ - 'desc', - 'describe', - 'doc', - 'docs', - 'dsc', - 'guide', - 'halp', - 'he', - 'hel', - 'hlp', - 'man', - 'mn', - 'usage', - ], - type: Boolean, - default_value: false, - description: 'Print this usage manual.', - examples: ['./publish/index.ts --help'], - }, - channel: { - name: 'Channel', - flag: 'channel', - type: String, - default_value: async (options: Map) => { - const gitState = await getGitState(options); - return gitState.expectedChannel; - }, - validate: (value: unknown) => { - if (!['lts', 'release', 'beta', 'canary', 'lts-prev', 'release-prev'].includes(value as string)) { - throw new Error(`Channel must be one of lts, release, beta, canary, lts-prev, or release-prev. Got ${value}`); - } - }, - description: - 'EmberData always publishes to a "release channel".\nTypically this will be one of lts, release, beta, or canary.\nWhen publishing a new version of a non-current lts or non-current release, the channel should be "lts-prev" or "release-prev"', - examples: ['./publish/index.ts lts', './publish/index.ts publish lts', './publish/index.ts --channel=lts'], - positional: true, - positional_index: 0, - // required: true, - }, - dry_run: { - name: 'Dry Run', - flag: 'dry_run', - flag_mispellings: ['dry'], - default_value: false, - description: 'Do not actually publish, just print what would be done', - type: Boolean, - examples: ['./publish/index.ts --channel=stable --dry_run'], - }, - dangerously_force: { - name: 'Force Release', - flag: 'dangerously_force', - flag_mispellings: [], - default_value: false, - description: 'Ignore safety checks and attempt to create and publish a release anyway', - type: Boolean, - examples: ['./publish/index.ts --channel=stable --dangerously_force'], - }, - tag: { - name: 'NPM Distribution Tag', - flag: 'tag', - flag_aliases: ['t'], - flag_mispellings: ['dist_tag'], - type: String, - description: '', - examples: [], - default_value: async (options: Map) => { - const gitInfo = await getGitState(options); - return npmDistTagForChannelAndVersion(gitInfo.expectedChannel, gitInfo.rootVersion); - }, - validate: async (value: unknown, options: Map) => { - const channel = options.get('channel') as CHANNEL; - const gitInfo = await getGitState(options); - const expectedTag = npmDistTagForChannelAndVersion(channel, gitInfo.rootVersion); - if (value !== expectedTag) { - if (!options.get('dangerously_force')) { - throw new Error( - `Expected npm dist-tag ${expectedTag} for channel ${channel} on branch ${gitInfo.branch} with version ${gitInfo.rootVersion} but got ${value}` - ); - } else { - console.log( - chalk.red( - `\t🚨 Expected npm dist-tag ${expectedTag} for channel ${channel} on branch ${ - gitInfo.branch - } with version ${gitInfo.rootVersion} but got ${value}\n\t\t${chalk.yellow( - '⚠️ Continuing Due to use of --dangerously-force' - )}` - ) - ); - } - } - }, - }, - increment: { - name: 'Version Increment', - flag: 'increment', - flag_aliases: ['i', 'b'], - flag_mispellings: ['inc', 'bump', 'incr'], - description: 'kind of version bump to perform, if any', - type: String, - examples: [], - default_value: 'patch', - validate: (value: unknown) => { - if (!['major', 'minor', 'patch'].includes(value as string)) { - throw new Error(`the 'increment' option must be one of 'major', 'minor' or 'patch'`); - } - }, - }, - upstream: { - name: 'Update Upstream Branch', - flag: 'upstream', - flag_aliases: ['u'], - flag_mispellings: ['upstraem', 'up'], - description: 'Whether to push the commits and tag upstream', - type: Boolean, - examples: [], - default_value: true, - }, - pack: { - name: 'Pack Packages', - flag: 'pack', - flag_aliases: ['p'], - flag_mispellings: ['skip-pack'], - description: 'whether to pack tarballs for the public packages', - type: Boolean, - examples: [], - default_value: true, - }, - publish: { - name: 'Publish Packages to NPM', - flag: 'publish', - flag_aliases: ['r'], - flag_mispellings: ['skip-publish', 'skip-release', 'release'], - description: 'whether to publish the packed tarballs to the npm registry', - type: Boolean, - examples: [], - default_value: true, - }, -}; - -export const command_config: CommandConfig = { - help: { - name: 'Help', - cmd: 'help', - description: 'Output This Manual', - alt: Array.from(HELP), - example: '$ ./publish/index.ts help', - }, - about: { - name: 'About', - cmd: 'about', - description: 'Print Information About This Script', - alt: Array.from(ABOUT), - example: '$ ./publish/index.ts about', - }, - // retag: {}, - default: { - name: 'Publish', - cmd: 'publish', - default: true, - description: 'Publish a new version of EmberData to the specified channel.', - options: flags_config, - example: ['$ ./publish/index.ts', '$ ./publish/index.ts publish'], - }, -}; - -export function getCommands() { - const keys = Object.keys(command_config); - const commands = new Map(); - keys.forEach((key) => { - const cmd = normalizeFlag(key); - commands.set(cmd, cmd); - if (command_config[cmd].alt) { - command_config[cmd].alt!.forEach((alt: string) => { - const alternate = normalizeFlag(alt); - if (commands.has(alternate) && commands.get(alternate) !== cmd) { - throw new Error(`Duplicate command alias ${alternate} for ${cmd} and ${commands.get(alternate)}`); - } - commands.set(alternate, cmd); - }); - } - }); - - return commands; -} diff --git a/release/core/promote/index.ts b/release/core/promote/index.ts new file mode 100644 index 00000000000..14fea7c3f68 --- /dev/null +++ b/release/core/promote/index.ts @@ -0,0 +1,136 @@ +import { promote_flags_config } from '../../utils/flags-config'; +import { parseRawFlags } from '../../utils/parse-args'; +import { GIT_TAG, getAllPackagesForGitTag, getGitState, pushLTSTagToRemoteBranch } from '../../utils/git'; +import { printHelpDocs } from '../../help/docs'; +import { Package } from '../../utils/package'; +import { SEMVER_VERSION } from '../../utils/channel'; +import chalk from 'chalk'; +import { colorName } from '../publish/steps/print-strategy'; +import { exec } from '../../utils/cmd'; +import { question } from '../publish/steps/confirm-strategy'; + +export async function promoteToLTS(args: string[]) { + // get user supplied config + const config = await parseRawFlags(args.slice(1), promote_flags_config); + const gitTag: GIT_TAG = `v${config.full.get('version') as SEMVER_VERSION}`; + + if (config.full.get('help')) { + return printHelpDocs(args); + } + + const packages = await getAllPackagesForGitTag(gitTag); + const versionsToPromote = getPublicPackageVersions(packages); + + await updateTags(config.full, versionsToPromote); + if (config.full.get('upstream') && !config.full.get('dry_run')) { + try { + await pushLTSTagToRemoteBranch(gitTag, true); + } catch (e) { + console.error(chalk.red(`NPM Tag Updated, but failed to update the remote lts branch for ${gitTag}`)); + console.error(e); + } + } +} + +export function getPublicPackageVersions(packages: Map): Map { + const publicPackages = new Map(); + packages.forEach((pkg, name) => { + if (!pkg.pkgData.private) { + publicPackages.set(name, pkg.pkgData.version); + } + }); + return publicPackages; +} + +export async function updateTags( + config: Map, + packages: Map +) { + const distTag = config.get('tag') as string; + const NODE_AUTH_TOKEN = process.env.NODE_AUTH_TOKEN; + const CI = process.env.CI; + let token: string | undefined; + + // allow OTP token usage locally + if (!NODE_AUTH_TOKEN) { + if (CI) { + console.log( + chalk.red( + '🚫 NODE_AUTH_TOKEN not found in ENV. NODE_AUTH_TOKEN is required in ENV to publish from CI. Exiting...' + ) + ); + process.exit(1); + } + token = await getOTPToken(distTag); + } else { + if (!CI) { + const result = await question( + `\n${chalk.cyan('NODE_AUTH_TOKEN')} found in ENV.\nPublish ${config.get('increment')} release in ${config.get( + 'channel' + )} channel to the ${config.get('tag')} tag on the npm registry? ${chalk.yellow('[y/n]')}:` + ); + const input = result.trim().toLowerCase(); + if (input !== 'y' && input !== 'yes') { + console.log(chalk.red('🚫 Publishing not confirmed. Exiting...')); + process.exit(1); + } + } + } + + const dryRun = config.get('dry_run') as boolean; + + for (const [pkgName, version] of packages) { + token = await updateDistTag(pkgName, version, distTag, dryRun, token); + console.log(chalk.green(`\t✅ ${colorName(pkgName)} ${chalk.green(version)} => ${chalk.magenta(distTag)}`)); + } + + console.log( + `✅ ` + chalk.cyan(`Moved ${chalk.greenBright(packages.size)} 📦 packages to ${chalk.magenta(distTag)} channel`) + ); +} + +async function getOTPToken(distTag: string, reprompt?: boolean) { + const prompt = reprompt + ? `The provided OTP token has expired. Please enter a new OTP token: ` + : `\nℹ️ ${chalk.cyan( + 'NODE_AUTH_TOKEN' + )} not found in ENV.\n\nConfiguring NODE_AUTH_TOKEN is the preferred mechanism by which to publish. Alternatively you may continue using an OTP token.\n\nUpdating ${distTag} tag on the npm registry.\n\nEnter your OTP token: `; + + let token = await question(prompt); + + return token.trim(); +} + +async function updateDistTag( + pkg: string, + version: string, + distTag: string, + dryRun: boolean, + otp?: string +): Promise { + let cmd = `npm dist-tag add ${pkg}@${version} ${distTag}`; + + if (otp) { + cmd += ` --otp=${otp}`; + } + + if (dryRun) { + cmd += ' --dry-run'; + } + + try { + await exec({ cmd, condense: true }); + } catch (e) { + if (!otp || !(e instanceof Error)) { + throw e; + } + if (e.message.includes('E401') || e.message.includes('EOTP')) { + otp = await getOTPToken(distTag, true); + return updateDistTag(pkg, version, distTag, dryRun, otp); + } else { + throw e; + } + } + + return otp; +} diff --git a/publish/core/publish.ts b/release/core/publish/index.ts similarity index 66% rename from publish/core/publish.ts rename to release/core/publish/index.ts index 07776d2ce4a..70bcd7347e0 100644 --- a/publish/core/publish.ts +++ b/release/core/publish/index.ts @@ -1,17 +1,18 @@ -import { flags_config } from '../utils/flags-config'; -import { parseRawFlags } from '../utils/parse-args'; -import { getGitState } from '../utils/git'; -import { printHelpDocs } from '../help/docs'; -import { bumpAllPackages, restorePackagesForDryRun } from './publish/steps/bump-versions'; -import { generatePackageTarballs } from './publish/steps/generate-tarballs'; -import { printStrategy } from './publish/steps/print-strategy'; -import { applyStrategy, gatherPackages, loadStrategy } from './publish/steps/generate-strategy'; -import { confirmStrategy } from './publish/steps/confirm-strategy'; -import { publishPackages } from './publish/steps/publish-packages'; +import { publish_flags_config } from '../../utils/flags-config'; +import { parseRawFlags } from '../../utils/parse-args'; +import { getGitState } from '../../utils/git'; +import { printHelpDocs } from '../../help/docs'; +import { bumpAllPackages, restorePackagesForDryRun } from './steps/bump-versions'; +import { generatePackageTarballs } from './steps/generate-tarballs'; +import { printStrategy } from './steps/print-strategy'; +import { applyStrategy } from './steps/generate-strategy'; +import { confirmStrategy } from './steps/confirm-strategy'; +import { publishPackages } from './steps/publish-packages'; +import { gatherPackages, loadStrategy } from '../../utils/package'; export async function executePublish(args: string[]) { // get user supplied config - const config = await parseRawFlags(args, flags_config); + const config = await parseRawFlags(args, publish_flags_config); if (config.full.get('help')) { return printHelpDocs(args); @@ -20,7 +21,7 @@ export async function executePublish(args: string[]) { const dryRun = config.full.get('dry_run') as boolean; // get git info - const gitInfo = await getGitState(config.full); + await getGitState(config.full); // get configured strategy const strategy = await loadStrategy(); @@ -29,7 +30,7 @@ export async function executePublish(args: string[]) { const packages = await gatherPackages(strategy.config); // get applied strategy - const applied = await applyStrategy(config.full, gitInfo, strategy, packages); + const applied = await applyStrategy(config.full, strategy, packages); // print strategy to be applied await printStrategy(config.full, applied); diff --git a/publish/core/publish/steps/bump-versions.ts b/release/core/publish/steps/bump-versions.ts similarity index 95% rename from publish/core/publish/steps/bump-versions.ts rename to release/core/publish/steps/bump-versions.ts index d369f791a3d..6926bf2ef4a 100644 --- a/publish/core/publish/steps/bump-versions.ts +++ b/release/core/publish/steps/bump-versions.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { exec } from '../../../utils/cmd'; -import { APPLIED_STRATEGY, Package } from '../../utils/package'; +import { APPLIED_STRATEGY, Package } from '../../../utils/package'; /** * This function will consume the strategy, bump the versions of all packages, @@ -33,7 +33,7 @@ export async function bumpAllPackages( await pkg.file.write(); } - const willPublish: boolean = config.get('pack') && config.get('publish'); + const willPublish: boolean = Boolean(config.get('pack') && config.get('publish')); const dryRun = config.get('dry_run') as boolean; const nextVersion = strategy.get('root')?.toVersion; let commitCommand = `git commit -am "Release v${nextVersion}"`; diff --git a/release/core/publish/steps/confirm-strategy.ts b/release/core/publish/steps/confirm-strategy.ts new file mode 100644 index 00000000000..bd729e531b3 --- /dev/null +++ b/release/core/publish/steps/confirm-strategy.ts @@ -0,0 +1,41 @@ +import chalk from 'chalk'; +import * as readline from 'readline/promises'; + +export async function confirmStrategy() { + return confirm({ + prompt: chalk.white(`\nDo you want to continue with this strategy?`), + cancelled: chalk.red('🚫 Strategy not confirmed. Exiting...'), + }); +} + +/** + * Prompt user to continue, exit if not confirmed. + * + * In CI environments, this function will return immediately without prompting. + * + * config.prompt - The prompt to display to the user + * config.cancelled - The message to display if the user cancels + * + * yes/no prompt will be added to the end of the prompt text automatically. + * + * @internal + */ +export async function confirm(config: { prompt: string; cancelled: string }): Promise { + if (process.env.CI) { + return; + } + const confirm = await question(`${config.prompt} ${chalk.yellow(`[y/n]`)}: `); + const input = confirm.trim().toLowerCase(); + if (input !== 'y' && input !== 'yes') { + console.log(config.cancelled); + process.exit(1); + } +} + +export async function question(prompt: string) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return rl.question(prompt); +} diff --git a/release/core/publish/steps/generate-strategy.ts b/release/core/publish/steps/generate-strategy.ts new file mode 100644 index 00000000000..70c1044a2c1 --- /dev/null +++ b/release/core/publish/steps/generate-strategy.ts @@ -0,0 +1,130 @@ +import chalk from 'chalk'; +import { CHANNEL, npmDistTagForChannelAndVersion } from '../../../utils/channel'; + +import { APPLIED_STRATEGY, Package, STRATEGY } from '../../../utils/package'; +import { getNextVersion } from '../../utils/next-version'; +import path from 'path'; +import semver from 'semver'; + +const PROJECT_ROOT = process.cwd(); + +function sortByName(map: Map) { + const sorted = [...map.values()]; + sorted.sort((a, b) => { + if (a.name.startsWith('@') && !b.name.startsWith('@')) { + return 1; + } + + if (!a.name.startsWith('@') && b.name.startsWith('@')) { + return -1; + } + return a.name > b.name ? 1 : -1; + }); + map.clear(); + sorted.forEach((v) => { + map.set(v.name, v); + }); +} + +function getPkgDir(pkgFilePath: string) { + const relative = path.relative(PROJECT_ROOT, pkgFilePath); + const parts = relative.split('/'); + if (parts.length === 1) { + return ''; + } + return '/' + parts[0]; +} + +export async function applyStrategy( + config: Map, + strategy: STRATEGY, + baseVersionPackages: Map, + toPackages: Map = baseVersionPackages +): Promise { + const channel = config.get('channel') as CHANNEL; + const increment = config.get('increment') as 'major' | 'minor' | 'patch'; + const applied_strategies = new Map(); + const private_pkgs = new Map(); + const public_pks = new Map(); + const isReversion = baseVersionPackages !== toPackages; + const newBaseVersion = baseVersionPackages.get('root')!.pkgData.version; + const currentVersion = toPackages.get('root')!.pkgData.version; + // if we are downversioning, then the currentVersion root will have a higher version than the newBaseVersion + // + // a downversion occurs when for instance we decide to release a new stable patch from current beta or main + const isDownversion = isReversion && semver.gt(currentVersion, newBaseVersion); + if (isDownversion) { + console.log( + `\n\n\t==========================================\n\t⚠️\t${chalk.yellow( + 'Down-Versioning Detected:' + )}\n\t\tConverting primary version from ${chalk.greenBright(currentVersion)} to ${chalk.greenBright( + newBaseVersion + )} before applying strategy for ${increment} bump.\n\n\t\tAlpha and Beta packages will be marked as private.\n\t==========================================\n` + ); + } + + for (const [name, pkg] of toPackages) { + const rule = strategy.rules[name] || strategy.defaults; + const applied_strategy = Object.assign({}, rule) as APPLIED_STRATEGY; + const fromPkg = baseVersionPackages.get(name); + + applied_strategy.name = name; + applied_strategy.private = Boolean(pkg.pkgData.private); + applied_strategy.pkgDir = getPkgDir(pkg.filePath); + applied_strategy.fromVersion = fromPkg ? fromPkg.pkgData.version : pkg.pkgData.version; + applied_strategy.new = !fromPkg; + + if (isDownversion) { + // during a downversion, we do not allow publishing a package whose current strategy is + // alpha or beta. + // this is because any version bump could conflict with the version in the canary channel. + // so for instance, if we have canary of an alpha project at 0.0.1-alpha.5, + // any downversion bump would result in 0.0.1 + // but if we were to downversion the primary version from say 5.4.0-alpha.1 to both 5.3.1 and 5.2.1, + // then we would have a conflict as both would try to publish the alpha version at 0.0.1 + if (rule.stage === 'alpha' || rule.stage === 'beta') { + applied_strategy.private = true; // always mark as private to avoid a new publish + applied_strategy.toVersion = pkg.pkgData.version; // preserve the existing version + pkg.pkgData.private = true; // mark the package as private, we will save this when applying version changes later + } + + // handle packages that didn't exist in the fromPackages + else if (!fromPkg && rule.stage === 'stable') { + if (pkg.pkgData.version === currentVersion) { + applied_strategy.fromVersion = newBaseVersion; + } + + applied_strategy.toVersion = getNextVersion(applied_strategy.fromVersion, channel, increment, rule.stage); + } else { + applied_strategy.toVersion = getNextVersion(applied_strategy.fromVersion, channel, increment, rule.stage); + } + } else { + applied_strategy.toVersion = getNextVersion(applied_strategy.fromVersion, channel, increment, rule.stage); + } + + // channels may not change outside of a major or minor bump + // major and minor bumps may only occur on beta|canary|release|lts + // and never lts-* or release-* and so existing fromVersion is safe + // to use. + applied_strategy.distTag = npmDistTagForChannelAndVersion(channel, applied_strategy.fromVersion); + applied_strategies.set(name, applied_strategy); + + applied_strategy.private ? private_pkgs.set(name, applied_strategy) : public_pks.set(name, applied_strategy); + } + + sortByName(applied_strategies); + sortByName(private_pkgs); + sortByName(public_pks); + + return { + all: applied_strategies, + private_pkgs, + public_pks, + }; +} + +export type AppliedStrategy = { + all: Map; + private_pkgs: Map; + public_pks: Map; +}; diff --git a/publish/core/publish/steps/generate-tarballs.ts b/release/core/publish/steps/generate-tarballs.ts similarity index 99% rename from publish/core/publish/steps/generate-tarballs.ts rename to release/core/publish/steps/generate-tarballs.ts index ed05325e1a5..7cd1e595bac 100644 --- a/publish/core/publish/steps/generate-tarballs.ts +++ b/release/core/publish/steps/generate-tarballs.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { exec } from '../../../utils/cmd'; -import { APPLIED_STRATEGY, Package } from '../../utils/package'; +import { APPLIED_STRATEGY, Package } from '../../../utils/package'; import path from 'path'; import fs from 'fs'; import { Glob } from 'bun'; diff --git a/publish/core/publish/steps/print-strategy.ts b/release/core/publish/steps/print-strategy.ts similarity index 93% rename from publish/core/publish/steps/print-strategy.ts rename to release/core/publish/steps/print-strategy.ts index aa4dfe8961f..6e7263554e8 100644 --- a/publish/core/publish/steps/print-strategy.ts +++ b/release/core/publish/steps/print-strategy.ts @@ -48,9 +48,12 @@ function printTable(title: string, rows: string[][]) { } export async function printStrategy(config: Map, applied: AppliedStrategy) { - const tableRows = [['Name', 'From Version', 'To Version', 'Stage', 'Types', 'NPM Dist Tag', 'Status', 'Location']]; + const tableRows = [ + [' ', 'Name', 'From Version', 'To Version', 'Stage', 'Types', 'NPM Dist Tag', 'Status', 'Location'], + ]; applied.public_pks.forEach((applied, name) => { tableRows.push([ + applied.new ? chalk.magentaBright('New!') : '', colorName(name), chalk.grey(applied.fromVersion), chalk[COLORS_BY_STRATEGY[applied.stage]](applied.toVersion), @@ -69,6 +72,7 @@ export async function printStrategy(config: Map, reprompt?: boolean) { +export async function getOTPToken(config: Map, reprompt?: boolean) { const prompt = reprompt ? `The provided OTP token has expired. Please enter a new OTP token: ` : `\nℹ️ ${chalk.cyan( diff --git a/release/core/release-notes/index.ts b/release/core/release-notes/index.ts new file mode 100644 index 00000000000..a745aea5d3b --- /dev/null +++ b/release/core/release-notes/index.ts @@ -0,0 +1,57 @@ +import { parseRawFlags } from '../../utils/parse-args'; +import { printHelpDocs } from '../../help/docs'; +import { GIT_TAG, getAllPackagesForGitTag, getGitState } from '../../utils/git'; +import { gatherPackages, loadStrategy } from '../../utils/package'; +import { applyStrategy } from '../publish/steps/generate-strategy'; +import { printStrategy } from '../publish/steps/print-strategy'; +import { confirmStrategy } from '../publish/steps/confirm-strategy'; +import { release_notes_flags_config } from '../../utils/flags-config'; +import { SEMVER_VERSION } from '../../utils/channel'; +import { updateChangelogs } from './steps/update-changelogs'; +import { getChanges } from './steps/get-changes'; +import { confirmCommitChangelogs } from './steps/confirm-changelogs'; + +export async function executeReleaseNoteGeneration(args: string[]) { + // remove the command itself from the list + args.shift(); + + // get user supplied config + const config = await parseRawFlags(args, release_notes_flags_config); + + if (config.full.get('help')) { + return printHelpDocs(args); + } + + // get git info + await getGitState(config.full); + + // get configured strategy + const strategy = await loadStrategy(); + + // get packages present in the git tag version + const fromVersion = config.full.get('from') as SEMVER_VERSION; + const fromTag = `v${fromVersion}` as GIT_TAG; + const baseVersionPackages = await getAllPackagesForGitTag(fromTag); + + // get packages present on our current branch + const packages = await gatherPackages(strategy.config); + + // get applied strategy + const applied = await applyStrategy(config.full, strategy, baseVersionPackages, packages); + + // print strategy to be applied + await printStrategy(config.full, applied); + + // confirm we should continue + await confirmStrategy(); + + // generate the list of changes + const newChanges = await getChanges(strategy, packages, fromTag); + + // update all changelogs, including the primary changelog + // and the changelogs for each package in changelogRoots + // this will not commit the changes + const changedFiles = await updateChangelogs(fromTag, newChanges, config.full, strategy, packages, applied); + + await confirmCommitChangelogs(changedFiles, config.full, applied); +} diff --git a/release/core/release-notes/steps/confirm-changelogs.ts b/release/core/release-notes/steps/confirm-changelogs.ts new file mode 100644 index 00000000000..1c9c3a88683 --- /dev/null +++ b/release/core/release-notes/steps/confirm-changelogs.ts @@ -0,0 +1,43 @@ +import { BunFile } from 'bun'; +import { confirm } from '../../publish/steps/confirm-strategy'; +import { exec } from '../../../utils/cmd'; +import { SEMVER_VERSION } from '../../../utils/channel'; +import chalk from 'chalk'; +import { AppliedStrategy } from '../../publish/steps/generate-strategy'; + +export async function confirmCommitChangelogs( + _changedFiles: BunFile[], + config: Map, + strategy: AppliedStrategy +) { + const dryRun = config.get('dry_run') as boolean; + + if (config.get('commit') === false) { + console.log(chalk.grey(`\t➠ Skipped commit of changelogs.`)); + return; + } + + try { + await confirm({ + prompt: `Do you want to commit the changelogs?`, + cancelled: `🚫 Commit of changelogs cancelled. Exiting...`, + }); + } finally { + if (dryRun) { + // cleanup files because we're not actually committing + await exec(['sh', '-c', `git add -A && git reset --hard HEAD`]); + } + } + + if (!dryRun) { + const newVersion = strategy.all.get('root')!.toVersion; + await exec(['sh', '-c', `git add -A && git commit -m "chore: update changelogs for v${newVersion}"`]); + + if (config.get('upstream')) { + await exec(['sh', '-c', `git push`]); + console.log(chalk.grey(`\t✅ pushed changelog commit to upstream.`)); + } else { + console.log(chalk.grey(`\t➠ Skipped push of changelogs.`)); + } + } +} diff --git a/release/core/release-notes/steps/get-changes.ts b/release/core/release-notes/steps/get-changes.ts new file mode 100644 index 00000000000..bb80cf1794b --- /dev/null +++ b/release/core/release-notes/steps/get-changes.ts @@ -0,0 +1,161 @@ +import { exec } from '../../../utils/cmd'; +import { Package, STRATEGY } from '../../../utils/package'; +import path from 'path'; + +export const Committers = Symbol('Committers'); +export type Entry = { packages: string[]; description: string; committer: string }; +export interface LernaOutput { + [Committers]: Map; + [key: string]: Map; +} +export type LernaChangeset = { + data: LernaOutput; + byPackage: Record>>; +}; + +// e.g. match lines ending in "asljasdfjh ([@runspired](https://github.com/runspired))"" +const CommitterRegEx = /.*\s\(?\[@([a-zA-Z0-9-]+)\]\(https:\/\/github.com\/\1\)\)?$/; + +function keyForLabel(label: string, strategy: STRATEGY): string { + const labelKey = strategy.config.changelog?.collapseLabels?.labels.some((v) => v === label); + return labelKey ? strategy.config.changelog!.collapseLabels!.title : label; +} + +function packagesBySubPath(strategy: STRATEGY, packages: Map): Map { + const subPathMap = new Map(); + const changelogRoots = strategy.config.changelogRoots || strategy.config.packageRoots; + const changelogPaths = changelogRoots.map((v) => v.replace('/*', '')); + + for (const [, pkg] of packages) { + if (pkg.pkgData.name === 'root') { + subPathMap.set('root', pkg); + continue; + } + let relative = path.dirname(path.relative(process.cwd(), pkg.filePath)); + for (const root of changelogPaths) { + if (relative.startsWith(root + '/')) { + const shortPath = relative.substring(root.length + 1); + if (subPathMap.has(shortPath)) { + console.error(`Duplicate subpath: ${shortPath}`); + process.exit(1); + } + relative = shortPath; + break; + } + } + subPathMap.set(relative, pkg); + } + + const mappings = strategy.config.changelog?.mappings || {}; + Object.keys(mappings).forEach((mapping) => { + const mapped = mappings[mapping]; + if (mapped === null) { + subPathMap.set(mapping, packages.get('root')!); + return; + } + const pkg = packages.get(mapped); + if (!pkg) { + throw new Error(`Could not find package for mapping: ${mapping}`); + } + subPathMap.set(mapping, pkg); + }); + + return subPathMap; +} + +function packageForSubPath(strategy: STRATEGY, subPath: string, packages: Map): string { + const pkg = packages.get(subPath); + if (pkg) { + return pkg.pkgData.name; + } + throw new Error(`Could not find package for subpath: ${subPath}`); +} + +function parseLernaOutput(markdown: string, strategy: STRATEGY, packages: Map): LernaChangeset { + const subPathMap = packagesBySubPath(strategy, packages); + const data: LernaOutput = { + [Committers]: new Map(), + }; + const byPackage: Record>> = {}; + const lines = markdown.split('\n'); + + let isParsingCommitters = false; + let isParsingSection = false; + let currentSection = ''; + let currentEntry: Entry | null = null; + // console.log('lines', lines); + + for (const line of lines) { + if (isParsingSection) { + if (line === '') { + isParsingSection = false; + currentSection = ''; + } else { + if (line.startsWith('* ')) { + const packages = line + .substring(2) + .split(',') + .map((v) => v.trim().replaceAll('`', '')); + currentEntry = { + packages, + description: '', + committer: '', + }; + } else if (line.startsWith(' * ')) { + currentEntry = structuredClone(currentEntry!); + currentEntry!.description = line.substring(4); + const PRMatches = currentEntry!.description.match(/^\[#(\d+)/); + const PRNumber = PRMatches![1]; + + // e.g. ([@runspired](https://github.com/runspired)) + const committerMatches = currentEntry!.description.match(CommitterRegEx); + currentEntry!.committer = committerMatches![1]; + + (data[currentSection] as Map).set(PRNumber, currentEntry as Entry); + + currentEntry?.packages.forEach((subPath) => { + const pkg = packageForSubPath(strategy, subPath, subPathMap); + byPackage[pkg] = byPackage[pkg] || {}; + byPackage[pkg][currentSection] = byPackage[pkg][currentSection] || new Map(); + byPackage[pkg][currentSection].set(PRNumber, currentEntry as Entry); + }); + } else { + isParsingSection = false; + currentSection = ''; + currentEntry = null; + } + } + } else if (isParsingCommitters) { + if (line === '') { + isParsingCommitters = false; + } else { + const committerMatches = line.match(CommitterRegEx); + const committer = committerMatches![1]; + data[Committers].set(committer, line.substring(2)); + } + } else if (line.startsWith('#### ')) { + isParsingCommitters = false; + isParsingSection = false; + currentEntry = null; + if (line.startsWith('#### Committers:')) { + currentSection = 'Committers'; + isParsingCommitters = true; + } else { + currentSection = keyForLabel(line.substring(5), strategy); + data[currentSection] = data[currentSection] || new Map(); + isParsingSection = true; + } + } + } + + // Object.entries(data).forEach(([key, value]) => { + // console.log(key, value); + // }); + + return { data, byPackage }; +} + +export async function getChanges(strategy: STRATEGY, packages: Map, fromTag: string) { + const changelogMarkdown = await exec(['sh', '-c', `bunx lerna-changelog --from=${fromTag}`]); + return parseLernaOutput(changelogMarkdown, strategy, packages); +} diff --git a/release/core/release-notes/steps/submit-pr-to-branch.ts b/release/core/release-notes/steps/submit-pr-to-branch.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/release/core/release-notes/steps/update-changelogs.ts b/release/core/release-notes/steps/update-changelogs.ts new file mode 100644 index 00000000000..0d72d2ebe89 --- /dev/null +++ b/release/core/release-notes/steps/update-changelogs.ts @@ -0,0 +1,132 @@ +import { Package, STRATEGY } from '../../../utils/package'; +import { AppliedStrategy } from '../../publish/steps/generate-strategy'; +import { Committers, Entry, LernaChangeset } from './get-changes'; +import path from 'path'; +import chalk from 'chalk'; +import { BunFile } from 'bun'; + +function findInsertionPoint(lines: string[], version: string) { + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith(`## ${version}`)) { + return i; + } + } + return 2; +} + +function buildText( + newTag: string, + strategy: STRATEGY, + changes: Record>, + committerStrings: Map +): string[] { + // YYYY-MM-DD + const formattedDate = new Date().toISOString().split('T')[0]; + const committers = new Set(); + + const lines = [`## ${newTag} (${formattedDate})`, '']; + const order = strategy.config.changelog?.labelOrder || []; + const seen = new Set(); + + for (const section of order) { + const entries = changes[section]; + if (!entries) { + continue; + } + + lines.push(`#### ${section}`, ''); + for (const [pr, entry] of entries) { + committers.add(entry.committer); + lines.push(`* ${entry.description}`); + } + lines.push(''); + seen.add(section); + } + + for (const [section, entries] of Object.entries(changes)) { + if (section === 'Committers' || seen.has(section)) { + continue; + } + + lines.push(`#### ${section}`, ''); + for (const [pr, entry] of entries) { + committers.add(entry.committer); + lines.push(`* ${entry.description}`); + } + lines.push(''); + } + + lines.push(`#### Committers: (${committers.size})`, ''); + committers.forEach((committer) => { + // e.g. `* [@runspired](https://github.com/runspired)` + lines.push(committerStrings.get(committer)!); + }); + lines.push(''); + + return lines; +} + +export async function updateChangelogs( + fromTag: string, + newChanges: LernaChangeset, + config: Map, + strategy: STRATEGY, + packages: Map, + applied: AppliedStrategy +): Promise { + const file = Bun.file('./CHANGELOG.md'); + const mainChangelog = await file.text(); + const lines = mainChangelog.split('\n'); + const toVersion = applied.all.get('root')!.toVersion; + const toTag = `v${toVersion}`; + const newLines = buildText(toTag, strategy, newChanges.data, newChanges.data[Committers]); + const insertionPoint = findInsertionPoint(lines, fromTag); + lines.splice(insertionPoint, 0, ...newLines); + await Bun.write(file, lines.join('\n')); + console.log(`\t✅ Updated Primary Changelog`); + const changedFiles = [file]; + + for (const [pkgName, changes] of Object.entries(newChanges.byPackage)) { + if (pkgName === 'root') { + continue; + } + + const pkg = packages.get(pkgName); + if (!pkg) { + throw new Error(`Could not find package for name: ${pkgName}`); + } + const changelogFile = Bun.file(path.join(path.dirname(pkg.filePath), 'CHANGELOG.md')); + const exists = await changelogFile.exists(); + const toVersion = applied.all.get(pkgName)!.toVersion; + const toTag = `v${toVersion}`; + const fromVersion = applied.all.get(pkgName)!.fromVersion; + const fromTag = `v${fromVersion}`; + const newLines = buildText(toTag, strategy, changes, newChanges.data[Committers]); + changedFiles.push(changelogFile); + + let changelogLines: string[] = []; + if (!exists) { + changelogLines = [ + `# ${pkg.pkgData.name} Changelog`, + '', + `For the full project changelog see [https://github.com/emberjs/data/blob/main/CHANGELOG.md](https://github.com/emberjs/data/blob/main/CHANGELOG.md)`, + '', + ...newLines, + '', + ]; + } else { + changelogLines = (await changelogFile.text()).split('\n'); + const insertionPoint = findInsertionPoint(changelogLines, fromTag); + changelogLines.splice(insertionPoint, 0, ...newLines); + } + + await Bun.write(changelogFile, changelogLines.join('\n')); + console.log( + exists + ? `\t✅ Updated ${chalk.cyan(pkg.pkgData.name)} Changelog` + : `\t✅ Created ${chalk.cyan(pkg.pkgData.name)} Changelog` + ); + } + + return changedFiles; +} diff --git a/publish/core/utils/next-version.ts b/release/core/utils/next-version.ts similarity index 100% rename from publish/core/utils/next-version.ts rename to release/core/utils/next-version.ts diff --git a/release/guide/canary.md b/release/guide/canary.md new file mode 100644 index 00000000000..405119fd266 --- /dev/null +++ b/release/guide/canary.md @@ -0,0 +1,26 @@ +# Canary Release Guide + +## Automated Workflow + +The [Publish Canary Release](../../.github/workflows/release/publish-canary.yml) workflow should be used to publish all new canaries from the [Action Overview](https://github.com/emberjs/data/actions/workflows/release/publish-canary.yml). + +This workflow trigger is restricted to project maintainers. + +For the first release of a new cycle, manually running this flow with the increment as either `major` or `minor` is required. + +Subsequent pre-release versions will be auto-released on a chron schedule. + + +## Manually Canarying + +Ensure you have bun, node and pnpm configured correctly. Volta is preferred for managing +node and pnpm versions. For bun, any `1.x` version should work but minimum version should +ideally match the installed `bun-types` dependency `package.json`. + +We always release canary from the `main` branch, though forcing from another branch is possible if required in a last resort. + +```ts +bun release publish canary -i +``` + +Run `bun release help` for additional options. diff --git a/publish/help/-utils.ts b/release/help/-utils.ts similarity index 100% rename from publish/help/-utils.ts rename to release/help/-utils.ts diff --git a/publish/help/docs.ts b/release/help/docs.ts similarity index 58% rename from publish/help/docs.ts rename to release/help/docs.ts index c6986047b4e..f567cdb0388 100644 --- a/publish/help/docs.ts +++ b/release/help/docs.ts @@ -1,8 +1,28 @@ import chalk from 'chalk'; -import { command_config, flags_config } from '../utils/flags-config'; +import { command_config } from '../utils/flags-config'; import { Command, Flag } from '../utils/parse-args'; import { color, getNumTabs, getPadding, indent } from './-utils'; +function getDefaultValueDescriptor(value: unknown) { + if (typeof value === 'string') { + return chalk.green(`"${value}"`); + } else if (typeof value === 'number') { + return chalk.green(`${value}`); + } else if (typeof value === 'boolean') { + return chalk.green(`${value}`); + } else if (value === null) { + return chalk.green('null'); + } else if (typeof value === 'function') { + if (value.name) { + return chalk.cyan(`Function<${value.name}>`); + } else { + return chalk.cyan(`Function`); + } + } else { + return chalk.grey('N/A'); + } +} + function buildOptionDoc(flag: Flag, index: number): string { const { flag_aliases, flag_mispellings, description, examples } = flag; const flag_shape = @@ -11,12 +31,13 @@ function buildOptionDoc(flag: Flag, index: number): string { const flag_aliases_str = chalk.grey(flag_aliases?.join(', ') || 'N/A'); const flag_mispellings_str = chalk.grey(flag_mispellings?.join(', ') || 'N/A'); - return `${chalk.greenBright(flag.name)} ${flag_shape} -\t${chalk.yellow('aliases')}: ${flag_aliases_str} -\t${chalk.yellow('alt')}: ${flag_mispellings_str} -${indent(description, 1)} -\t${chalk.grey('Examples')}: -\t${examples + return `${flag_shape} ${chalk.greenBright(flag.name)} + ${indent(description, 1)} + ${chalk.yellow('default')}: ${getDefaultValueDescriptor(flag.default_value)} + ${chalk.yellow('aliases')}: ${flag_aliases_str} + ${chalk.yellow('alt')}: ${flag_mispellings_str} + ${chalk.grey('Examples')}: + ${examples .map((example) => { if (typeof example === 'string') { return example; @@ -38,18 +59,25 @@ function buildCommandDoc(command: Command, index: number): string { } const lines = [ - `cy<<${cmd}>>${getPadding(getNumTabs(cmd))}${description}`, + `cy<<${chalk.bold(cmd)}>>\n${indent(description, 1)}`, alt ? `\tye<>: gr<<${alt.join(', ')}>>` : '', overview ? `\t${overview}` : '', xmpl ? `\n\tgr<<${Array.isArray(example) ? 'Examples' : 'Example'}>>:` : '', xmpl ? `\t ${xmpl}\n` : '', ].filter(Boolean); + const opts = options ? Object.values(options) : []; + if (opts.length > 0) { + lines.push( + `\t${chalk.bold(chalk.yellowBright('Options'))}`, + indent(`${Object.values(opts).map(buildOptionDoc).join('\n\n')}`, 1) + ); + } + return color(lines.join('\n')); } export async function printHelpDocs(_args: string[]) { - const config = Object.values(flags_config); const commands = Object.values(command_config); console.log( @@ -62,8 +90,6 @@ $ ./publish/index.ts ${chalk.magentaBright('')} [options] ${chalk.bold('Commands')} ${commands.map(buildCommandDoc).join('\n ')} -${chalk.bold('Options')} - ${config.map(buildOptionDoc).join('\n ')} ` ) ); diff --git a/publish/help/sections/about.ts b/release/help/sections/about.ts similarity index 100% rename from publish/help/sections/about.ts rename to release/help/sections/about.ts diff --git a/publish/help/sections/manual.ts b/release/help/sections/manual.ts similarity index 100% rename from publish/help/sections/manual.ts rename to release/help/sections/manual.ts diff --git a/release/index.ts b/release/index.ts new file mode 100755 index 00000000000..35828e92ce6 --- /dev/null +++ b/release/index.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env bun +import chalk from 'chalk'; +import { printHelpDocs } from './help/docs'; +import { normalizeFlag } from './utils/parse-args'; +import { getCommands } from './utils/flags-config'; +import { printAbout } from './help/sections/about'; +import { executePublish } from './core/publish'; +import { executeReleaseNoteGeneration } from './core/release-notes'; +import { write } from './utils/write'; +import { promoteToLTS } from './core/promote'; + +const COMMANDS = { + help: printHelpDocs, + about: printAbout, + release_notes: executeReleaseNoteGeneration, + publish: executePublish, + promote: promoteToLTS, + default: executePublish, + exec: async (args: string[]) => { + args.shift(); + const cmd = args.shift(); + + if (!cmd) { + throw new Error('No command provided to exec'); + } + + const commands = getCommands(); + const cmdString = (commands.get(normalizeFlag(cmd)) as keyof typeof COMMANDS) || 'default'; + + const command = COMMANDS[cmdString]; + if (command) { + await command( + args.filter((arg) => { + return !arg.endsWith('='); + }) + ); + } else { + throw new Error(`Command not found: ${cmd}`); + } + }, +}; + +async function main() { + const args = Bun.argv.slice(2); + + write( + chalk.grey( + `\n\t${chalk.bold( + chalk.greenBright('Warp') + chalk.magentaBright('Drive') + )} | Automated Release\n\t==============================` + ) + chalk.grey(`\n\tengine: ${chalk.cyan('bun@' + Bun.version)}\n`) + ); + + const commandArg = args.length === 0 ? 'help' : normalizeFlag(args[0]); + const commands = getCommands(); + const cmdString = (commands.get(commandArg) as keyof typeof COMMANDS) || 'default'; + const cmd = COMMANDS[cmdString]; + + if (args.length && commands.has(commandArg)) { + args.shift(); + } + + await cmd(args); + process.exit(0); +} + +await main(); diff --git a/release/strategy.json b/release/strategy.json new file mode 100644 index 00000000000..69d5d6d4c9b --- /dev/null +++ b/release/strategy.json @@ -0,0 +1,56 @@ +{ + "config": { + "packageRoots": ["packages/*", "tests/*", "config"], + "changelogRoots": ["packages/*"], + "changelog": { + "labelOrder": [ + ":boom: Breaking Change", + ":evergreen_tree: New Deprecation", + ":memo: Documentation", + ":rocket: Enhancement", + ":bug: Bug Fix", + ":zap: Performance", + ":house: Internal" + ], + "collapseLabels": { + "labels": [":shower: Deprecation Removal", ":goal_net: Test", ":house: Internal"], + "title": ":house: Internal" + }, + "mappings": { + "mock-server": "@warp-drive/diagnostic", + "core": "@warp-drive/core-types", + "Other": null + } + } + }, + "defaults": { + "stage": "stable", + "types": "private" + }, + "rules": { + "@warp-drive/holodeck": { + "stage": "alpha", + "types": "private" + }, + "@warp-drive/diagnostic": { + "stage": "alpha", + "types": "private" + }, + "eslint-plugin-ember-data": { + "stage": "alpha", + "types": "private" + }, + "@warp-drive/core-types": { + "stage": "alpha", + "types": "private" + }, + "@warp-drive/schema": { + "stage": "alpha", + "types": "private" + }, + "@warp-drive/schema-record": { + "stage": "alpha", + "types": "private" + } + } +} diff --git a/publish/tsconfig.json b/release/tsconfig.json similarity index 100% rename from publish/tsconfig.json rename to release/tsconfig.json diff --git a/publish/utils/channel.ts b/release/utils/channel.ts similarity index 100% rename from publish/utils/channel.ts rename to release/utils/channel.ts diff --git a/publish/utils/cmd.ts b/release/utils/cmd.ts similarity index 98% rename from publish/utils/cmd.ts rename to release/utils/cmd.ts index d355ce3fa2d..36983d9deae 100644 --- a/publish/utils/cmd.ts +++ b/release/utils/cmd.ts @@ -8,6 +8,7 @@ type CMD = { condense?: boolean; lines?: number; silent?: boolean; + env?: Record; }; // async function step() { @@ -143,7 +144,7 @@ export async function exec(cmd: string[] | string | CMD, dryRun: boolean = false if (!dryRun) { if (isCmdWithConfig && cmd.condense) { const proc = Bun.spawn(args, { - env: process.env, + env: cmd.env || process.env, cwd, stderr: 'pipe', stdout: 'pipe', diff --git a/release/utils/flags-config.ts b/release/utils/flags-config.ts new file mode 100644 index 00000000000..a5536f9a59c --- /dev/null +++ b/release/utils/flags-config.ts @@ -0,0 +1,434 @@ +import { HELP } from '../help/sections/manual'; +import { ABOUT } from '../help/sections/about'; +import { normalizeFlag, type CommandConfig, type FlagConfig } from './parse-args'; +import { CHANNEL, SEMVER_VERSION, npmDistTagForChannelAndVersion } from './channel'; +import { getGitState, getPublishedChannelInfo } from './git'; +import chalk from 'chalk'; +import semver from 'semver'; + +/** + * Like Pick but returns an object type instead of a union type. + * + * @internal + */ +type Subset = { + [P in K]: T[P]; +}; + +/** + * Like Typescript Pick but For Runtime. + * + * @internal + */ +export function pick, K extends keyof T>(obj: T, keys: K[]): Subset { + const result = {} as Subset; + + for (const key of keys) { + result[key] = obj[key]; + } + + return result; +} + +/** + * Like Object.assign (is Object.assign) but ensures each arg and the result conform to T + * + * @internal + */ +export function merge(...args: T[]): T { + return Object.assign({}, ...args); +} + +export const publish_flags_config: FlagConfig = { + help: { + name: 'Help', + flag: 'help', + flag_aliases: ['h', 'm'], + flag_mispellings: [ + 'desc', + 'describe', + 'doc', + 'docs', + 'dsc', + 'guide', + 'halp', + 'he', + 'hel', + 'hlp', + 'man', + 'mn', + 'usage', + ], + type: Boolean, + default_value: false, + description: 'Print this usage manual.', + examples: ['./publish/index.ts --help'], + }, + channel: { + name: 'Channel', + flag: 'channel', + type: String, + default_value: async (options: Map) => { + const gitState = await getGitState(options); + return gitState.expectedChannel; + }, + validate: (value: unknown) => { + if (!['lts', 'release', 'beta', 'canary', 'lts-prev', 'release-prev'].includes(value as string)) { + throw new Error(`Channel must be one of lts, release, beta, canary, lts-prev, or release-prev. Got ${value}`); + } + }, + description: + 'EmberData always publishes to a "release channel".\nTypically this will be one of lts, release, beta, or canary.\nWhen publishing a new version of a non-current lts or non-current release, the channel should be "lts-prev" or "release-prev"', + examples: ['./publish/index.ts lts', './publish/index.ts publish lts', './publish/index.ts --channel=lts'], + positional: true, + positional_index: 0, + // required: true, + }, + dry_run: { + name: 'Dry Run', + flag: 'dry_run', + flag_mispellings: ['dry'], + default_value: false, + description: 'Do not actually publish, just print what would be done', + type: Boolean, + examples: ['./publish/index.ts --channel=stable --dry_run'], + }, + dangerously_force: { + name: 'Force Release', + flag: 'dangerously_force', + flag_mispellings: [], + default_value: false, + description: 'Ignore safety checks and attempt to create and publish a release anyway', + type: Boolean, + examples: ['./publish/index.ts --channel=stable --dangerously_force'], + }, + tag: { + name: 'NPM Distribution Tag', + flag: 'tag', + flag_aliases: ['t'], + flag_mispellings: ['dist_tag'], + type: String, + description: '', + examples: [], + default_value: async (options: Map) => { + const gitInfo = await getGitState(options); + return npmDistTagForChannelAndVersion(gitInfo.expectedChannel, gitInfo.rootVersion); + }, + validate: async (value: unknown, options: Map) => { + const channel = options.get('channel') as CHANNEL; + const gitInfo = await getGitState(options); + const expectedTag = npmDistTagForChannelAndVersion(channel, gitInfo.rootVersion); + if (value !== expectedTag) { + if (!options.get('dangerously_force')) { + throw new Error( + `Expected npm dist-tag ${expectedTag} for channel ${channel} on branch ${gitInfo.branch} with version ${gitInfo.rootVersion} but got ${value}` + ); + } else { + console.log( + chalk.red( + `\t🚨 Expected npm dist-tag ${expectedTag} for channel ${channel} on branch ${ + gitInfo.branch + } with version ${gitInfo.rootVersion} but got ${value}\n\t\t${chalk.yellow( + '⚠️ Continuing Due to use of --dangerously-force' + )}` + ) + ); + } + } + }, + }, + increment: { + name: 'Version Increment', + flag: 'increment', + flag_aliases: ['i', 'b'], + flag_mispellings: ['inc', 'bump', 'incr'], + description: 'kind of version bump to perform, if any.\nMust be one of "major", "minor", or "patch"', + type: String, + examples: [], + default_value: 'patch', + validate: (value: unknown) => { + if (!['major', 'minor', 'patch'].includes(value as string)) { + throw new Error(`the 'increment' option must be one of 'major', 'minor' or 'patch'`); + } + }, + }, + commit_changelog: { + name: 'Commit', + flag: 'commit_changelog', + flag_aliases: ['c'], + flag_mispellings: ['cm', 'comit', 'changelog'], + description: 'Whether to commit the changes to the changelogs', + type: Boolean, + examples: [], + default_value: true, + }, + // branch: { + // name: 'Update Local and Upstream Branch', + // flag: 'update_branch', + // flag_aliases: [], + // flag_mispellings: ['branch'], + // description: + // 'Whether to update the local and upstream branch according to the standard release channel flow. For release this will reset the branch to the current beta. For beta this will reset the branch to the current canary. For lts this will reset the branch to the current release. For lts-prev this is not a valid option.', + // type: Boolean, + // examples: [], + // default_value: false, + // }, + from: { + name: 'From Version', + flag: 'from', + flag_aliases: ['v'], + flag_mispellings: ['ver'], + description: 'The version from which to increment and build a strategy', + type: String, + examples: [], + default_value: async (options: Map) => { + return (await getPublishedChannelInfo(options)).latest; + }, + validate: async (value: unknown) => { + if (typeof value !== 'string') { + throw new Error(`Expected a string but got ${value}`); + } + if (value.startsWith('v')) { + throw new Error(`Version passed to promote should not start with 'v'`); + } + if (semver.valid(value) === null) { + throw new Error(`Version passed to promote is not a valid semver version`); + } + const versionInfo = semver.parse(value); + if (versionInfo?.prerelease?.length) { + throw new Error(`Version passed to promote cannot be prerelease version`); + } + }, + }, + upstream: { + name: 'Update Upstream Branch', + flag: 'upstream', + flag_aliases: ['u'], + flag_mispellings: ['upstraem', 'up'], + description: 'Whether to push the commits and tag upstream', + type: Boolean, + examples: [], + default_value: true, + }, + pack: { + name: 'Pack Packages', + flag: 'pack', + flag_aliases: ['p'], + flag_mispellings: ['skip-pack'], + description: 'whether to pack tarballs for the public packages', + type: Boolean, + examples: [], + default_value: true, + }, + publish: { + name: 'Publish Packages to NPM', + flag: 'publish', + flag_aliases: ['r'], + flag_mispellings: ['skip-publish', 'skip-release', 'release'], + description: 'whether to publish the packed tarballs to the npm registry', + type: Boolean, + examples: [], + default_value: true, + }, +}; + +export const release_notes_flags_config: FlagConfig = merge( + pick(publish_flags_config, ['help', 'increment', 'dry_run', 'dangerously_force', 'tag', 'channel', 'upstream']), + { + commit: { + name: 'Commit', + flag: 'commit', + flag_aliases: ['c'], + flag_mispellings: ['cm', 'comit'], + description: 'Whether to commit the changes to the changelogs', + type: Boolean, + examples: [], + default_value: true, + }, + from: { + name: 'From Version', + flag: 'from', + flag_aliases: ['v'], + flag_mispellings: ['ver', 'release', 'rel'], + description: 'The version from which to increment and build a strategy', + type: String, + examples: [], + default_value: async (options: Map) => { + return (await getPublishedChannelInfo(options)).latest; + }, + validate: async (value: unknown) => { + if (typeof value !== 'string') { + throw new Error(`Expected a string but got ${value}`); + } + if (value.startsWith('v')) { + throw new Error(`Version passed to promote should not start with 'v'`); + } + if (semver.valid(value) === null) { + throw new Error(`Version passed to promote is not a valid semver version`); + } + const versionInfo = semver.parse(value); + if (versionInfo?.prerelease?.length) { + throw new Error(`Version passed to promote cannot be prerelease version`); + } + }, + }, + } +); + +export const promote_flags_config: FlagConfig = merge( + pick(publish_flags_config, ['help', 'dry_run', 'dangerously_force', 'upstream']), + { + version: { + name: 'Version', + flag: 'version', + flag_aliases: ['v'], + flag_mispellings: ['ver', 'release', 'rel'], + description: 'The version to promote to LTS', + type: String, + examples: [], + default_value: async (options: Map) => { + return (await getPublishedChannelInfo(options)).latest; + }, + validate: async (value: unknown) => { + if (typeof value !== 'string') { + throw new Error(`Expected a string but got ${value}`); + } + if (value.startsWith('v')) { + throw new Error(`Version passed to promote should not start with 'v'`); + } + if (semver.valid(value) === null) { + throw new Error(`Version passed to promote is not a valid semver version`); + } + const versionInfo = semver.parse(value); + if (versionInfo?.prerelease?.length) { + throw new Error(`Version passed to promote cannot be prerelease version`); + } + }, + }, + tag: { + name: 'NPM Distribution Tag', + flag: 'tag', + flag_aliases: ['t'], + flag_mispellings: ['dist_tag'], + type: String, + description: '', + examples: [], + default_value: async (options: Map) => { + const version = options.get('version') as SEMVER_VERSION; + const existing = await getPublishedChannelInfo(options); + + if (existing.latest === version) { + return 'lts'; + } else { + return npmDistTagForChannelAndVersion('lts-prev', version); + } + }, + validate: async (value: unknown, options: Map) => { + let version = options.get('version') as SEMVER_VERSION; + const existing = await getPublishedChannelInfo(options); + + if (!version) { + version = (await getPublishedChannelInfo(options)).latest; + } + + if (value !== 'lts') { + // older lts channels should match lts-- + if (typeof value !== 'string' || !value.startsWith('lts-')) { + throw new Error(`Expected a tag starting with "lts-" but got ${value}`); + } + + const expected = npmDistTagForChannelAndVersion('lts-prev', version); + + if (expected !== value) { + throw new Error(`Expected tag lts or ${expected} for version ${version} but got ${value}`); + } + } + + if (existing[value] === version) { + throw new Error(`Version ${version} is already published to ${value}`); + } + + const current = existing[value]; + if (current && semver.lt(version, current)) { + throw new Error(`Version ${version} is less than the latest version ${current}`); + } + }, + }, + } +); + +export const command_config: CommandConfig = { + help: { + name: 'Help', + cmd: 'help', + description: 'Output This Manual', + alt: Array.from(HELP), + example: '$ bun release help', + }, + exec: { + name: 'Execute Command', + cmd: 'exec', + description: + 'Executes another release command with the provided arguments, filtering out any args with undefined values.', + alt: [], + example: '$ bun release exec promote --version=5.3.0 --tag=lts', + }, + about: { + name: 'About', + cmd: 'about', + description: 'Print Information About This Script', + alt: Array.from(ABOUT), + example: '$ bun release about', + }, + release_notes: { + name: 'Release Notes', + cmd: 'release-notes', + alt: ['cl', 'changes', 'history', 'notes', 'releasenotes', 'changelog', 'log'], + description: `Generate release notes for the next release.`, + options: release_notes_flags_config, + example: '$ bun release cl', + }, + promote: { + name: 'Promote to LTS', + cmd: 'promote', + description: + 'Promote a prior release to LTS.\nThis will upate the dist-tags on npm without publishing any new versions or tarballs', + alt: ['retag', 'lts', 'lts-promote'], + options: promote_flags_config, + example: [ + '$ bun release promote', + '$ bun release promote --version=5.3.0 --tag=lts', + '$ bun release promote 4.12.5 --tag=lts-4-12', + ], + }, + default: { + name: 'Publish', + cmd: 'publish', + default: true, + description: + 'Publish a new version of EmberData to the specified channel.\nRequires a configured ye<> with npm access to all associated scopes and packages,\nor the ability to generate an OTP token for the same.', + options: publish_flags_config, + example: ['$ bun release', '$ bun release publish'], + }, +}; + +export function getCommands() { + const keys = Object.keys(command_config); + const commands = new Map(); + keys.forEach((key) => { + const cmd = normalizeFlag(key); + commands.set(cmd, cmd); + commands.set(command_config[key].cmd, cmd); + if (command_config[cmd].alt) { + command_config[cmd].alt!.forEach((alt: string) => { + const alternate = normalizeFlag(alt); + if (commands.has(alternate) && commands.get(alternate) !== cmd) { + throw new Error(`Duplicate command alias ${alternate} for ${cmd} and ${commands.get(alternate)}`); + } + commands.set(alternate, cmd); + }); + } + }); + + return commands; +} diff --git a/publish/utils/git.ts b/release/utils/git.ts similarity index 65% rename from publish/utils/git.ts rename to release/utils/git.ts index 0595b10003a..a9194e024c1 100644 --- a/publish/utils/git.ts +++ b/release/utils/git.ts @@ -1,7 +1,31 @@ import chalk from 'chalk'; -import { branchForChannelAndVersion, CHANNEL, channelForBranch, SEMVER_VERSION, VALID_BRANCHES } from './channel'; +import { + branchForChannelAndVersion, + CHANNEL, + channelForBranch, + npmDistTagForChannelAndVersion, + SEMVER_VERSION, + VALID_BRANCHES, +} from './channel'; import { getFile } from './json-file'; import { exec } from './cmd'; +import { gatherPackages, loadStrategy, Package } from './package'; +import path from 'path'; + +export type LTS_TAG = `lts-${number}-${number}`; +export type RELEASE_TAG = `release-${number}-${number}`; +export type GIT_TAG = + | `v${number}.${number}.${number}` + | `v${number}.${number}.${number}-alpha.${number}` + | `v${number}.${number}.${number}-beta.${number}`; + +export type CHANNEL_VERSIONS = { + latest: SEMVER_VERSION; + beta: SEMVER_VERSION; + canary: SEMVER_VERSION; + lts: SEMVER_VERSION; + [key: LTS_TAG | RELEASE_TAG]: SEMVER_VERSION | undefined; +}; export type GIT_STATE = { rootVersion: SEMVER_VERSION; @@ -13,6 +37,17 @@ export type GIT_STATE = { expectedChannel: CHANNEL; }; +let _NPM_INFO: Record | null = null; +export async function getPublishedChannelInfo( + options: Map +): Promise { + if (!_NPM_INFO) { + const gitInfo = await exec(['npm', 'view', 'ember-data@latest', '--json']); + _NPM_INFO = JSON.parse(gitInfo) as Record; + } + return _NPM_INFO['dist-tags'] as CHANNEL_VERSIONS; +} + let _GIT_STATE: GIT_STATE | null = null; export async function getGitState(options: Map): Promise { if (_GIT_STATE) { @@ -124,3 +159,30 @@ export async function getGitState(options: Map> { + const relativeTmpDir = `./tmp/${tag}`; + await exec(['mkdir', '-p', relativeTmpDir]); + await exec({ cmd: ['sh', '-c', `git archive ${tag} | tar -xC ${relativeTmpDir}`] }); + + const tmpDir = path.join(process.cwd(), relativeTmpDir); + try { + const strategy = await loadStrategy(tmpDir); + return gatherPackages(strategy.config, tmpDir); + } catch (e) { + // if strategy does not exist we may be pre-strategy days + // so we will just gather all packages from the packages directory + + return gatherPackages({ packageRoots: ['packages/*'] }, tmpDir); + } +} + +export async function pushLTSTagToRemoteBranch(tag: GIT_TAG, force?: boolean): Promise { + const sha = await exec({ cmd: `git rev-list -n 1 ${tag}` }); + const branch = npmDistTagForChannelAndVersion('lts-prev', tag.slice(1) as SEMVER_VERSION); + const oldSha = await exec({ cmd: `git rev-list -n 1 refs/heads/${branch}` }); + let cmd = `git push origin refs/tags/${tag}:refs/heads/${branch}`; + if (force) cmd += ' -f'; + await exec({ cmd }); + console.log(chalk.green(`✅ Pushed ${tag} to ${branch} (${oldSha.slice(0, 10)} => ${sha.slice(0, 10)})`)); +} diff --git a/publish/utils/json-file.ts b/release/utils/json-file.ts similarity index 100% rename from publish/utils/json-file.ts rename to release/utils/json-file.ts diff --git a/release/utils/package.ts b/release/utils/package.ts new file mode 100644 index 00000000000..2917c15ffee --- /dev/null +++ b/release/utils/package.ts @@ -0,0 +1,130 @@ +import { JSONFile, getFile } from './json-file'; +import { NPM_DIST_TAG, SEMVER_VERSION, STRATEGY_TYPE, TYPE_STRATEGY } from './channel'; +import { Glob } from 'bun'; +import path from 'path'; +export class Package { + declare filePath: string; + declare file: JSONFile; + declare pkgData: PACKAGEJSON; + declare tarballPath: string; + + constructor(filePath: string, file: JSONFile, pkgData: PACKAGEJSON) { + this.filePath = filePath; + this.file = file; + this.pkgData = pkgData; + this.tarballPath = ''; + } + + async refresh() { + await this.file.invalidate(); + this.pkgData = await this.file.read(); + } +} + +/** + * A valid package.json file can go up to 3 levels deep + * when defining the exports field. + * + * ``` + * { + * "exports": { + * ".": "./index.js", + * "main": { + * "import": "./index.js", + * "require": "./index.js" + * "browser": { + * "import": "./index.js", + * "require": "./index.js" + * } + * } + * } + * } + * ``` + * + * @internal + */ +type ExportConfig = Record>>; + +export type PACKAGEJSON = { + name: string; + version: SEMVER_VERSION; + private: boolean; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + scripts?: Record; + files?: string[]; + exports?: ExportConfig; +}; + +export type APPLIED_STRATEGY = { + name: string; + private: boolean; + stage: STRATEGY_TYPE; + types: TYPE_STRATEGY; + fromVersion: SEMVER_VERSION; + toVersion: SEMVER_VERSION; + distTag: NPM_DIST_TAG; + pkgDir: string; + new: boolean; +}; + +export interface STRATEGY { + config: { + packageRoots: string[]; + changelogRoots?: string[]; + changelog?: { + collapseLabels?: { + labels: string[]; + title: string; + }; + labelOrder?: string[]; + mappings: Record; + }; + }; + defaults: { + stage: STRATEGY_TYPE; + types: TYPE_STRATEGY; + }; + rules: Record< + string, + { + stage: STRATEGY_TYPE; + types: TYPE_STRATEGY; + } + >; +} + +function buildGlob(dirPath: string) { + return `${dirPath}/package.json`; +} + +export async function gatherPackages(config: STRATEGY['config'], cwd: string = process.cwd()) { + const packages: Map = new Map(); + + // add root + const rootFilePath = `${cwd}/package.json`; + const rootFile = getFile(rootFilePath); + const rootPkgData = await rootFile.read(); + packages.set('root', new Package(rootFilePath, rootFile, rootPkgData)); + + // add other packages + for (const dirPath of config.packageRoots) { + const glob = new Glob(buildGlob(dirPath)); + + // Scans the current working directory and each of its sub-directories recursively + for await (const filePath of glob.scan(cwd)) { + const file = getFile(path.join(cwd, filePath)); + const pkgData = await file.read(); + packages.set(pkgData.name, new Package(filePath, file, pkgData)); + } + } + + return packages; +} + +export async function loadStrategy(cwd: string = process.cwd()) { + const file = getFile(`${cwd}/release/strategy.json`); + const data = await file.read(); + return data; +} diff --git a/publish/utils/parse-args.ts b/release/utils/parse-args.ts similarity index 99% rename from publish/utils/parse-args.ts rename to release/utils/parse-args.ts index 3ea32665b9f..5922e4a517d 100644 --- a/publish/utils/parse-args.ts +++ b/release/utils/parse-args.ts @@ -312,7 +312,7 @@ export async function parseRawFlags( throw new Error(config.required_error || `Missing required flag: ${flag}`); } - const val = await processMissingFlag(config, processed_flags); + const val = await processMissingFlag(config, full_flags); full_flags.set(flag, val); } diff --git a/publish/utils/write.ts b/release/utils/write.ts similarity index 100% rename from publish/utils/write.ts rename to release/utils/write.ts