diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000000..122827e026 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,27 @@ +# Categorization for the auto-generated release notes — both the `--generate-notes` call in the +# GitHub Release job of workflows/publish.yml and the "Generate release notes" button. +# +# Categories match on labels, which workflows/pr-label.yml derives from the conventional-commit +# type in the pull request title. Order matters: the first matching category wins, so a dependency +# bump that also carries a hand-applied `bug` label is listed above Bug Fixes on purpose. +changelog: + categories: + - title: ⚠️ Breaking Changes + labels: + - breaking changes + - title: 📦 Dependencies + labels: + - dependencies + - title: 🚀 Features + labels: + - enhancement + - title: 🐛 Bug Fixes + labels: + - bug + - title: 📖 Documentation + labels: + - documentation + # chore, build, refactor, test — everything the labeler does not classify. + - title: 🧰 Other Changes + labels: + - '*' diff --git a/.github/workflows/actions/generate-changelog/action.yml b/.github/workflows/actions/generate-changelog/action.yml deleted file mode 100644 index 872a176d26..0000000000 --- a/.github/workflows/actions/generate-changelog/action.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Generate Changelog -description: '' - -outputs: - changelog: - description: '' - value: ${{ steps.generate-changelog.outputs.changelog }} - -runs: - using: composite - steps: - # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter - - name: Extract current changelog - id: generate-changelog - shell: bash - run: | - yarn run release:extract-changelog - changelog=$(cat ./CHANGELOG_CURRENT.md) - - # Output multiline strings: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings - EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) - echo "changelog<<$EOF" >> $GITHUB_OUTPUT - echo "$changelog" >> $GITHUB_OUTPUT - echo "$EOF" >> $GITHUB_OUTPUT - - - name: Show result - shell: bash - run: | - echo "$CHANGELOG" - env: - CHANGELOG: ${{ steps.generate-changelog.outputs.changelog }} diff --git a/.github/workflows/pr-label.yml b/.github/workflows/pr-label.yml new file mode 100644 index 0000000000..cee5327466 --- /dev/null +++ b/.github/workflows/pr-label.yml @@ -0,0 +1,92 @@ +name: Label PR + +# pull_request_target so pull requests opened from a fork are labeled too: the pull_request token +# is read-only for them. Nothing is checked out and no pull-request-controlled code runs here — +# only the title and body are read, and they reach the script through the environment. +on: + pull_request_target: + types: + - opened + - edited + - reopened + +permissions: + contents: read + +jobs: + label: + name: Label + runs-on: ubuntu-latest + # Dependabot applies `dependencies` to its own pull requests. + # `edited` fires for body/base edits too, not just the title. `changes.title` only exists (and + # is truthy) when the title itself changed; it's simply absent — not an error — on `opened` and + # `reopened`, which have no `changes` object at all, so checking `action != 'edited'` first + # covers those without needing to read it. + if: >- + ${{ github.repository_owner == 'koobiq' && github.actor != 'dependabot[bot]' + && (github.event.action != 'edited' || github.event.changes.title) }} + permissions: + pull-requests: write # to add labels to the pull request + steps: + - name: Apply the labels matching the conventional-commit type + env: + GH_TOKEN: ${{ github.token }} + # Nothing is checked out here, and gh does not fall back to GITHUB_REPOSITORY. + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + # Already shaped by commitlint.yml, but still untrusted text: keep it out of the script. + PR_TITLE: ${{ github.event.pull_request.title }} + # Free-form text with no validation at all (commitlint only checks PR_TITLE) — kept out + # of the script the same way. + PR_BODY: ${{ github.event.pull_request.body }} + run: | + set -euo pipefail + + # These feed the categories in .github/release.yml. The scope is what separates a + # dependency bump from an ordinary chore. + labels=() + + case "$PR_TITLE" in + feat*) labels+=(enhancement) ;; + fix*) labels+=(bug) ;; + docs*) labels+=(documentation) ;; + esac + + # A separate, independent check: `case` only fires its first match, and a dependency + # bump commonly carries a type prefix too (e.g. `fix(deps): bump x`). release.yml lists + # Dependencies above Bug Fixes specifically to handle a PR that carries both labels. + case "$PR_TITLE" in + *'(deps)'*|*'(deps-dev)'*) labels+=(dependencies) ;; + esac + + # `feat(scope)!:` — the conventional-commit marker for a breaking change. + case "$PR_TITLE" in + *'!:'*) labels+=('breaking changes') ;; + esac + + # The footer form, per the conventional-commits spec — checked separately since it lives + # in the description, not the title. + case "$PR_BODY" in + *'BREAKING CHANGE:'*|*'BREAKING-CHANGE:'*) + # Title and body can both flag it — guard against adding the label twice. + case "${labels[*]-}" in + *'breaking changes'*) ;; + *) labels+=('breaking changes') ;; + esac + ;; + esac + + if [ ${#labels[@]} -eq 0 ]; then + echo "No label maps to \"$PR_TITLE\"." + exit 0 + fi + + # Re-adding a label the pull request already carries is a no-op, so the `edited` and + # `reopened` re-runs are free. Labels are only ever added: a title corrected from `feat:` + # to `fix:` keeps the stale `enhancement` until someone removes it by hand. + add_label_flags=() + for label in "${labels[@]}"; do + add_label_flags+=(--add-label "$label") + done + + gh pr edit "$PR_NUMBER" "${add_label_flags[@]}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 70128f83fb..47c7aaa0b4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,6 +6,14 @@ on: - '*.*.*' # - '[0-9]+.[0-9]+.[0-9]+' # - '*@*.*.*' for NX integration + # Entry point for a tag npm already has: a backfill for tags released before the release job + # existed, a retry after a failed run, and the way an 18.x tag gets its release, since that + # branch keeps its own copy of this file. + workflow_dispatch: + inputs: + tag: + description: Existing tag to create the GitHub release for, for example 19.8.4 + required: true # Read-only default; the write scopes this workflow needs are granted on the job itself, so a # future job added here does not silently inherit them. @@ -19,7 +27,8 @@ jobs: # `check-npm-resolution` gates this job on the npm registry answering; a stall must not hold the # release runner for the six-hour default. timeout-minutes: 60 - if: ${{ github.repository_owner == 'koobiq' }} + # A manual run only creates the missing GitHub release; republishing to npm is never a button. + if: ${{ github.repository_owner == 'koobiq' && github.event_name == 'push' }} permissions: contents: read # for actions/checkout to read the repository packages: write @@ -49,3 +58,98 @@ jobs: # send NPM_TOKEN_KOOBIQ over an unverified connection. This flag reaches only the # notification request in packages/cli/src/release/notify-release.ts. MATTERMOST_ALLOW_UNTRUSTED_TLS: 'true' + + github-release: + name: GitHub Release + runs-on: ubuntu-latest + needs: publish + # Two near-simultaneous tag pushes (e.g. 20.5.0 and 19.9.0) would otherwise both read the same + # "current latest release" (below) before either finishes creating its own, and the Latest + # badge could land on whichever finishes last — even the older release train. One queued run + # at a time closes that window. The group is a fixed string, never templated with the ref/tag: + # the whole point is serializing across DIFFERENT tags, not the same one. + concurrency: + group: github-release-latest-badge + cancel-in-progress: false # queue, never cancel a release that's already being created + # `!cancelled() && !failure()` lets the manual path run while `publish` is skipped, but not if + # `publish` failed or the run was cancelled. Kept a separate job so the write scope below never + # shares a process with NPM_PUBLISH_TOKEN, and nothing is checked out here, so no repository + # code runs with it either. + if: ${{ !cancelled() && !failure() && github.repository_owner == 'koobiq' }} + permissions: + contents: write # for `gh release create` to publish the release + steps: + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + # Nothing is checked out here, and gh does not fall back to GITHUB_REPOSITORY. + GH_REPO: ${{ github.repository }} + # Read through the environment, never interpolated into the script: on a manual run this + # is free text, and a git tag may legally carry shell metacharacters. + TAG: ${{ inputs.tag || github.ref_name }} + run: | + set -euo pipefail + + # The push filter is looser than the grammar parse-version.ts accepts, and the dispatch + # input is not validated at all. + if ! printf '%s' "$TAG" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$'; then + echo "::error::\"$TAG\" is not a release tag." + exit 1 + fi + + # A re-run, or a dispatch for a tag already handled, must neither fail nor duplicate. + # `gh release view` prints exactly "release not found" (exit 1) when the tag has no + # release yet — verified against this repo with `gh` 2.97.0. Any other failure (auth, + # rate limit, a transient GitHub outage) also exits 1 but with different text, and must + # not be silently treated as "go ahead and create it". + if release_error=$(gh release view "$TAG" 2>&1 1>/dev/null); then + echo "Release $TAG already exists — nothing to do." + exit 0 + elif [ "$release_error" != "release not found" ]; then + echo "::error::gh release view \"$TAG\" failed: $release_error" + exit 1 + fi + + # `make_latest` defaults to true on the REST API and gh sends nothing when --latest is + # absent, so left implicit a 19.x patch tagged after 20.x would take the badge off 20.x. + # Decide it here: the badge moves only when this tag outranks the release holding it. + prerelease=() + latest=false + + case "$TAG" in + *-*) + prerelease=(--prerelease) # a prerelease can never be latest + ;; + *) + # Same distinction as above, and the same message: a repository with no releases at + # all answers "release not found" too — verified against github/gitignore with `gh` + # 2.97.0 — and there the first release does take the badge. Any other failure would + # otherwise read as "nothing holds the badge" and move it here, so it fails instead. + # The two streams stay separate: stdout carries the tag name, stderr the message. + if ! current=$(gh release view --json tagName --jq .tagName 2> "$RUNNER_TEMP/latest-release.err"); then + latest_error=$(cat "$RUNNER_TEMP/latest-release.err") + + if [ "$latest_error" != "release not found" ]; then + echo "::error::gh release view --json tagName failed: $latest_error" + exit 1 + fi + + current='' + fi + + if [ -z "$current" ] || [ "$(printf '%s\n%s\n' "$TAG" "$current" | sort -V | tail -1)" = "$TAG" ]; then + latest=true + fi + ;; + esac + + # --verify-tag: without it a typo in the dispatch input makes gh create a brand new tag on + # the default branch. --generate-notes reproduces the body every release has carried so + # far; GitHub resolves the previous tag from the tag's own ancestry, so the range stays + # right across the parallel 19.x / 20.x trains and for a late backfill. + gh release create "$TAG" \ + --verify-tag \ + --generate-notes \ + --title "$TAG" \ + --latest="$latest" \ + "${prerelease[@]}" diff --git a/docs/guides/05-releasing-packages.md b/docs/guides/05-releasing-packages.md index 32ebd8f741..442a867214 100644 --- a/docs/guides/05-releasing-packages.md +++ b/docs/guides/05-releasing-packages.md @@ -2,41 +2,101 @@ **Note: Releasing should only be done by the maintainers.** -Stable packages releasing only from `release branches`. +The current major is released from `main`. Older supported lines are released from their own branches +— `19.x` and `18.x` — see [security.md](../../.github/security.md) for which lines are still supported. -## Release Major version +## Releasing -1. Create 1.0.x branch from `master`; -2. Run +1. Check out the branch you are releasing from: `main` for the current major, `19.x` or `18.x` for a + patch to an older line. - `yarn run release:stage:commit` +2. Run: -This command create and push a release commit and tag with changelog and bumped `package.json`; + ```bash + yarn run release:stage:commit + ``` -CLI steps: + The CLI walks through the release and asks for confirmation at each step that needs it: -- (**need validation**) choose bump version -- (**need validation**) create `release name` - - you can use [angular-release-name-generator](https://www.npmjs.com/package/angular-release-name-generator) -- (**need validation**) create changelog -- create a commit with changelog -- create a git tag -- pushed changes to current branch + - (**needs confirmation**) choose the bump: major, minor or patch + - (**needs confirmation**) enter a `release name` + - you can use [angular-release-name-generator](https://www.npmjs.com/package/angular-release-name-generator) + - (**needs confirmation**) generate the changelog section + - commit the bumped `package.json` and `CHANGELOG.md` as `chore: bump version to X.Y.Z w/ changelog` + - create a signed annotated git tag `X.Y.Z` whose message is the changelog section + - push the branch and the tag -3. Just wait CircleCI job. +3. Wait for the **Publish** workflow ([publish.yml](../../.github/workflows/publish.yml)), which the + tag push starts. It builds every package, publishes the five packages listed under `release.packages` + in the root `package.json` to npm, notifies Mattermost, and then — in a separate job — creates the + GitHub release from the tag with auto-generated notes. -## Release Minor version + For a `20.*.*` tag, [docs-stable.yml](../../.github/workflows/docs-stable.yml) also deploys the docs + and re-runs the Algolia crawler. -1. Create a new branch from existing release branch; +Nothing has to be done on github.com afterwards. The release body is GitHub's auto-generated pull +request list, grouped by the categories in [release.yml](../../.github/release.yml); those categories +match on labels, which [pr-label.yml](../../.github/workflows/pr-label.yml) derives from the +conventional-commit type in each pull request title. -For example: +The `Latest` badge is decided by the workflow, not by GitHub's default, so a patch to an older line +published after a newer major does not take the badge from it. -current branch `3.0.x` and new branch `3.1.x`; +> A tag pushed to `18.x` runs that branch's own copy of `publish.yml`, which has no release job. Create +> its GitHub release with the manual run below. -2. Repeat `2` and `3` from previous steps. +## Creating a release for an existing tag -## Release Patch version +Use this to retry after a failed run, or for a tag whose release is missing. -1. No need it creates a new branch. Use existing release branch.; +**Actions → Publish → Run workflow**, then enter the tag. Only the GitHub release is created; the +manual run never republishes to npm. If the release already exists the run is a no-op. -2. Repeat `2` and `3` from previous steps, bump as patch. +The same thing locally, for several tags at once, as drafts to review first: + +```bash +for t in 19.8.4 19.8.5; do gh release view "$t" > /dev/null 2>&1 && continue; gh release create "$t" --verify-tag --generate-notes --title "$t" --latest=false --draft; done +``` + +Then publish them: + +```bash +for t in 19.8.4 19.8.5; do gh release edit "$t" --draft=false; done +``` + +Pass `--latest=false` unless the tag really is the newest version across every line — omitting the flag +makes GitHub mark it `Latest`. + +Tags on the abandoned 17.x line are deliberately left without releases. + +## Recovering from a bad release + +```bash +# published to GitHub but npm failed — remove the release and keep the tag +gh release delete "$TAG" --yes + +# the Latest badge landed on the wrong release +gh release edit "$TAG" --latest=false +gh release edit 20.2.0 --latest + +# regenerate the notes of a release that is already live +gh api repos/koobiq/angular-components/releases/generate-notes -f tag_name="$TAG" --jq .body > notes.md +gh release edit "$TAG" --notes-file notes.md + +# stop the automation without opening a pull request +gh workflow disable Publish +``` + +Never pass `--cleanup-tag` to `gh release delete`: the tag is what the published npm packages were +built from. + +## Verifying a change to the release pipeline + +Do **not** push a throwaway `*.*.*` tag to `origin` to test it. That runs the real `npm publish` with the +real token, and a `20.*` tag also triggers a docs deploy and an Algolia crawl. + +Preview what a release body would look like without writing anything: + +```bash +gh api repos/koobiq/angular-components/releases/generate-notes -f tag_name=19.8.4 --jq .body +``` diff --git a/packages/cli/src/release/git/github-urls.ts b/packages/cli/src/release/git/github-urls.ts index 516f0ff020..397e249d1b 100644 --- a/packages/cli/src/release/git/github-urls.ts +++ b/packages/cli/src/release/git/github-urls.ts @@ -2,19 +2,3 @@ export function getGithubBranchCommitsUrl(owner: string, repository: string, branchName: string) { return `https://github.com/${owner}/${repository}/commits/${branchName}`; } - -/** Gets a Github URL that can be used to create a new release from a given tag. */ -export function getGithubNewReleaseUrl(options: { - owner: string; - repository: string; - tagName: string; - releaseTitle: string; - body: string; -}) { - return ( - `https://github.com/${options.owner}/${options.repository}/releases/new?` + - `tag=${encodeURIComponent(options.tagName)}&` + - `title=${encodeURIComponent(options.releaseTitle)}&` + - `body=${encodeURIComponent(options.body)}` - ); -} diff --git a/packages/cli/src/release/publish-release-github-ci.ts b/packages/cli/src/release/publish-release-github-ci.ts index b27137e1e9..9279975e4b 100644 --- a/packages/cli/src/release/publish-release-github-ci.ts +++ b/packages/cli/src/release/publish-release-github-ci.ts @@ -79,8 +79,6 @@ export class PublishReleaseCIGithubTask extends BaseReleaseTask { console.info(green(bold(` ✓ Notification to Mattermost, version: ${newVersionName}`))); await notify(extractedReleaseNotes); } - - console.info(green(` ✓ Github release is posted.`)); } /** Publishes the specified package within the given NPM dist tag. */