From 82842b20400c3bc13b66a772094f4cc6267dad95 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 12 Aug 2026 13:12:21 +0300 Subject: [PATCH 1/3] chore: create the GitHub release automatically on tag push (#DS-3378) A tag push already publishes to npm, but the GitHub release was still drafted by hand on github.com, and that step gets forgotten: 19.8.4 and 19.8.5 are on npm with no release, and 18.39.5, 19.0.0 and 19.1.0 never got one either. publish.yml grows a second job running `gh release create --generate-notes` on the built-in token, so the body stays what the "Generate release notes" button produced. It is a separate job from the one holding NPM_PUBLISH_TOKEN, checks nothing out, and runs only once npm publishing succeeded. workflow_dispatch takes a tag for a backfill or a retry and skips the publish job, so the button can never republish to npm. `--latest` is computed rather than left implicit: the REST API defaults make_latest to true, so a 19.x patch tagged after 20.x would have taken the badge off 20.x. release.yml groups the generated notes, and pr-label.yml derives the labels those categories match on from the conventional-commit type in the pull request title. Over the last 84 merged pull requests 26 carried no label at all, so the grouping needs a source it can rely on. Drops the scaffolding this replaces: the "Github release is posted" line that never called the API, the unreferenced generate-changelog action, and the unused getGithubNewReleaseUrl(). A tag on 19.x runs that branch's own copy of publish.yml, so the job has to be backported there separately. --- .github/release.yml | 27 +++++ .../actions/generate-changelog/action.yml | 31 ----- .github/workflows/pr-label.yml | 62 ++++++++++ .github/workflows/publish.yml | 79 ++++++++++++- docs/guides/05-releasing-packages.md | 106 ++++++++++++++---- packages/cli/src/release/git/github-urls.ts | 16 --- .../src/release/publish-release-github-ci.ts | 2 - 7 files changed, 250 insertions(+), 73 deletions(-) create mode 100644 .github/release.yml delete mode 100644 .github/workflows/actions/generate-changelog/action.yml create mode 100644 .github/workflows/pr-label.yml 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..2cee09d511 --- /dev/null +++ b/.github/workflows/pr-label.yml @@ -0,0 +1,62 @@ +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 is read, and it reaches 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. + if: ${{ github.repository_owner == 'koobiq' && github.actor != 'dependabot[bot]' }} + 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 }} + 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) ;; + *'(deps)'*|*'(deps-dev)'*) labels+=(dependencies) ;; + esac + + # `feat(scope)!:` — the conventional-commit marker for a breaking change. + case "$PR_TITLE" in + *'!:'*) labels+=('breaking changes') ;; + 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. + for label in "${labels[@]}"; do + gh pr edit "$PR_NUMBER" --add-label "$label" + done diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 70128f83fb..38e53c2c4d 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,71 @@ 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 + # `always()` lets the manual path run while `publish` is skipped; the result check keeps a tag + # push from announcing a version that never reached npm. 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: >- + ${{ always() && github.repository_owner == 'koobiq' + && (needs.publish.result == 'success' || needs.publish.result == 'skipped') }} + 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. + if gh release view "$TAG" > /dev/null 2>&1; then + echo "Release $TAG already exists — nothing to do." + exit 0 + 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 + ;; + *) + current=$(gh release view --json tagName --jq .tagName 2> /dev/null || true) + + 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. */ From 7cf2f5ae72efef475094dfccc8a36774d4fcc04f Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 12 Aug 2026 16:31:32 +0300 Subject: [PATCH 2/3] fix: close release-pipeline gaps found in review (#DS-3378) - pr-label.yml: split the dependencies check into its own case block so a fix(deps)/feat(deps)/docs(deps) title still gets the dependencies label instead of losing it to the first-match type check; detect the BREAKING CHANGE: footer form, not just the !: title marker; skip no-op edits and batch the label calls. - publish.yml: serialize the github-release job so two concurrent tag pushes can't race for the Latest badge; simplify the always() condition to the idiomatic !cancelled() && !failure(); stop treating any gh release view failure as "release not found". --- .github/workflows/pr-label.yml | 36 +++++++++++++++++++++++++++++++--- .github/workflows/publish.yml | 29 +++++++++++++++++++-------- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pr-label.yml b/.github/workflows/pr-label.yml index 2cee09d511..cee5327466 100644 --- a/.github/workflows/pr-label.yml +++ b/.github/workflows/pr-label.yml @@ -2,7 +2,7 @@ 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 is read, and it reaches the script through the environment. +# only the title and body are read, and they reach the script through the environment. on: pull_request_target: types: @@ -18,7 +18,13 @@ jobs: name: Label runs-on: ubuntu-latest # Dependabot applies `dependencies` to its own pull requests. - if: ${{ github.repository_owner == 'koobiq' && github.actor != 'dependabot[bot]' }} + # `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: @@ -30,6 +36,9 @@ jobs: 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 @@ -41,6 +50,12 @@ jobs: 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 @@ -49,6 +64,18 @@ jobs: *'!:'*) 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 @@ -57,6 +84,9 @@ jobs: # 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 - gh pr edit "$PR_NUMBER" --add-label "$label" + 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 38e53c2c4d..ef51ba1be0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -63,13 +63,19 @@ jobs: name: GitHub Release runs-on: ubuntu-latest needs: publish - # `always()` lets the manual path run while `publish` is skipped; the result check keeps a tag - # push from announcing a version that never reached npm. 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: >- - ${{ always() && github.repository_owner == 'koobiq' - && (needs.publish.result == 'success' || needs.publish.result == 'skipped') }} + # 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: @@ -92,9 +98,16 @@ jobs: fi # A re-run, or a dispatch for a tag already handled, must neither fail nor duplicate. - if gh release view "$TAG" > /dev/null 2>&1; then + # `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 From 6f2968e9f403882eb55b635ffea192ce66b0b50c Mon Sep 17 00:00:00 2001 From: lskramarov Date: Thu, 13 Aug 2026 10:38:23 +0300 Subject: [PATCH 3/3] fix: fail the release job when the latest-release lookup errors (#DS-3378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The badge decision read the release currently holding Latest with `2> /dev/null || true`, so auth, rate-limit and transient GitHub failures all came back as an empty `current` — indistinguishable from "this repository has no releases yet". Empty means "nothing holds the badge", so a 19.x patch tagged during a GitHub blip would have created itself with `--latest=true` and taken the badge off 20.x. Verified against the pre-fix line with a stubbed gh: a 503 on the lookup yields `--latest=true` for tag 19.9.0. Same treatment the existence check above already got: "release not found" is the answer for a repository with no releases at all — checked against github/gitignore with gh 2.97.0, it is the same string the tag lookup returns — and there the first release does legitimately take the badge. Anything else fails the job. stdout and stderr stay separate here because stdout carries the tag name, so the message goes through a file rather than the `2>&1 1>/dev/null` swap used above. --- .github/workflows/publish.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ef51ba1be0..47c7aaa0b4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -121,7 +121,21 @@ jobs: prerelease=(--prerelease) # a prerelease can never be latest ;; *) - current=$(gh release view --json tagName --jq .tagName 2> /dev/null || true) + # 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