From 4bb50b6e839fc7b3ffff38269ac6b2380fc3708c Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Tue, 1 Sep 2026 18:52:28 +0200 Subject: [PATCH 1/3] fix(ci): publish to npm when release-please cuts a release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow had one job. It tagged, wrote the GitHub release, moved the major alias — and stopped. Three tags exist that npm never saw; the only published version came from a manual run. Two pieces were missing. The job exposed no outputs, so nothing downstream could learn whether a release happened: step outputs are invisible across jobs. And there was no publish job to consume them. dev now reaches the workflow for the prerelease job, with release-please itself gated to the release branch, matching coverage-report. scripts/backfill-npm.sh publishes the tags that were missed. It walks them oldest-first under a throwaway dist-tag, because npm moves latest to whatever went out last — an old version published after a new one would otherwise become what npm install hands out. --- .github/workflows/release-please.yml | 46 +++++++++++++++++ scripts/backfill-npm.sh | 75 ++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100755 scripts/backfill-npm.sh diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 5e486d8..b7734c5 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -6,6 +6,9 @@ on: push: branches: - main + # dev reaches this workflow for the prerelease job only; release-please + # itself is gated to the release branch below. + - dev permissions: contents: write @@ -19,6 +22,12 @@ jobs: release-please: name: Run release-please runs-on: ubuntu-latest + if: github.ref_name == 'main' + # Step outputs are invisible to other jobs, so the publish job below could + # never learn whether a release was cut without lifting them here. + outputs: + release-created: ${{ steps.release.outputs.release_created }} + tag-name: ${{ steps.release.outputs.tag_name }} steps: - name: Fetch Release App PEM from Bitwarden uses: bitwarden/sm-action@1238aae8fc64b212641190a9227c8a734ab1a793 # v3.0.1 @@ -79,3 +88,40 @@ jobs: -f "ref=refs/tags/$alias" -f "sha=$SHA" > /dev/null echo "::notice::Created $alias at $TAG ($SHA)." fi + + publish-release: + name: Publish stable release + needs: release-please + if: needs.release-please.outputs.release-created == 'true' + permissions: + contents: read + # npm Trusted Publishing (OIDC) and the provenance attestation. No + # NPM_TOKEN exists in this repo; npm verifies this workflow against the + # trusted publisher configured on the package. + id-token: write + # `build-script` is left at its default: this repo's `build` writes the npm + # artifact itself. coverage-report needs `build:npm` there only because its + # `build` produces an Action bundle instead. + uses: kirchDev/workflows/.github/workflows/_publish-npm.yml@c7bd7b9 # unreleased — replace with the tag once workflows cuts v0.1.0 + with: + tag-name: ${{ needs.release-please.outputs.tag-name }} + + publish-prerelease: + name: Publish prerelease + # Skip the version-bump merges release-please pushes onto main — those are + # the stable release, and publishing a prerelease from them would shadow it. + if: >- + ${{ + github.ref_name == 'dev' + || ( + github.ref_name == 'main' + && !startsWith(github.event.head_commit.message, 'chore(main): release') + && !startsWith(github.event.head_commit.message, 'chore: release') + ) + }} + permissions: + contents: read + id-token: write + uses: kirchDev/workflows/.github/workflows/_publish-npm.yml@c7bd7b9 # unreleased — replace with the tag once workflows cuts v0.1.0 + with: + prerelease: true diff --git a/scripts/backfill-npm.sh b/scripts/backfill-npm.sh new file mode 100755 index 0000000..06fd7b8 --- /dev/null +++ b/scripts/backfill-npm.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# +# Publishes every released tag that never reached npm. +# +# The publish job was missing from release-please.yml for the first releases, +# so tags exist that the registry does not know. This walks them oldest-first, +# builds each from its own tree, and publishes it. +# +# Order matters: npm moves the `latest` dist-tag to whatever was published +# last, so an old version published after a new one would make `npm install` +# hand out the old one. Everything below the newest therefore goes out under a +# throwaway tag, and `latest` is set once at the end. +# +# Needs `npm login` first. Provenance is off because it needs an OIDC token +# that only GitHub Actions issues — once the workflow publishes, it is on. +set -euo pipefail + +PKG="@kirchdev/gitignore-sync" +DRY="${1:-}" + +command -v jq >/dev/null || { echo "jq wird gebraucht"; exit 1; } +[ -z "$(git status --porcelain)" ] || { echo "Arbeitsbaum nicht sauber — abgebrochen."; exit 1; } + +start_branch=$(git rev-parse --abbrev-ref HEAD) +restore() { git switch -q "$start_branch" 2>/dev/null || git switch -q --detach "$start_branch"; } +trap restore EXIT + +# Der bewegliche v-Alias laesst ein normales --tags fehlschlagen, sobald +# er sich verschoben hat. Genau dafuer ist --force da. +git fetch -q origin --tags --force + +published=$(npm view "$PKG" versions --json 2>/dev/null | jq -r '. | if type == "array" then .[] else . end' || echo "") +tags=$(git tag -l 'v[0-9]*' | grep -vE '^v[0-9]+$' | sort -V) + +missing=() +for tag in $tags; do + v="${tag#v}" + grep -qxF "$v" <<<"$published" || missing+=("$tag") +done + +if [ ${#missing[@]} -eq 0 ]; then + echo "Nichts nachzupflegen — npm kennt jede getaggte Version." + exit 0 +fi + +newest="${missing[${#missing[@]}-1]}" +echo "Fehlt auf npm: ${missing[*]}" +echo "Neueste (bekommt latest): $newest" +[ "$DRY" = "--dry-run" ] && { echo "(dry run — nichts publiziert)"; exit 0; } + +for tag in "${missing[@]}"; do + echo + echo "── $tag ─────────────────────────────────" + git switch -q --detach "$tag" + CI=true pnpm install --frozen-lockfile >/dev/null + pnpm build >/dev/null + v=$(jq -r .version package.json) + [ "$v" = "${tag#v}" ] || { echo "package.json sagt $v, Tag sagt ${tag#v} — übersprungen."; continue; } + + if [ "$tag" = "$newest" ]; then + npm publish --no-provenance + else + # Unter einem Wegwerf-Tag, damit `latest` nicht auf eine alte Version faellt. + npm publish --no-provenance --tag backfill + fi +done + +echo +npm dist-tag ls "$PKG" | sed 's/^/ /' +if npm dist-tag ls "$PKG" | grep -q '^backfill:'; then + npm dist-tag rm "$PKG" backfill + echo "Wegwerf-Tag 'backfill' entfernt." +fi +echo +echo "npm steht jetzt auf: $(npm view "$PKG" version)" From c16cf8472693934a3db832c1362b8e6215f6952b Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Tue, 1 Sep 2026 19:08:23 +0200 Subject: [PATCH 2/3] chore: drop the npm backfill script A one-off for the three tags that were cut while release-please.yml had no publish job. That gap is closed, so the script has nothing left to do. --- scripts/backfill-npm.sh | 75 ----------------------------------------- 1 file changed, 75 deletions(-) delete mode 100755 scripts/backfill-npm.sh diff --git a/scripts/backfill-npm.sh b/scripts/backfill-npm.sh deleted file mode 100755 index 06fd7b8..0000000 --- a/scripts/backfill-npm.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -# -# Publishes every released tag that never reached npm. -# -# The publish job was missing from release-please.yml for the first releases, -# so tags exist that the registry does not know. This walks them oldest-first, -# builds each from its own tree, and publishes it. -# -# Order matters: npm moves the `latest` dist-tag to whatever was published -# last, so an old version published after a new one would make `npm install` -# hand out the old one. Everything below the newest therefore goes out under a -# throwaway tag, and `latest` is set once at the end. -# -# Needs `npm login` first. Provenance is off because it needs an OIDC token -# that only GitHub Actions issues — once the workflow publishes, it is on. -set -euo pipefail - -PKG="@kirchdev/gitignore-sync" -DRY="${1:-}" - -command -v jq >/dev/null || { echo "jq wird gebraucht"; exit 1; } -[ -z "$(git status --porcelain)" ] || { echo "Arbeitsbaum nicht sauber — abgebrochen."; exit 1; } - -start_branch=$(git rev-parse --abbrev-ref HEAD) -restore() { git switch -q "$start_branch" 2>/dev/null || git switch -q --detach "$start_branch"; } -trap restore EXIT - -# Der bewegliche v-Alias laesst ein normales --tags fehlschlagen, sobald -# er sich verschoben hat. Genau dafuer ist --force da. -git fetch -q origin --tags --force - -published=$(npm view "$PKG" versions --json 2>/dev/null | jq -r '. | if type == "array" then .[] else . end' || echo "") -tags=$(git tag -l 'v[0-9]*' | grep -vE '^v[0-9]+$' | sort -V) - -missing=() -for tag in $tags; do - v="${tag#v}" - grep -qxF "$v" <<<"$published" || missing+=("$tag") -done - -if [ ${#missing[@]} -eq 0 ]; then - echo "Nichts nachzupflegen — npm kennt jede getaggte Version." - exit 0 -fi - -newest="${missing[${#missing[@]}-1]}" -echo "Fehlt auf npm: ${missing[*]}" -echo "Neueste (bekommt latest): $newest" -[ "$DRY" = "--dry-run" ] && { echo "(dry run — nichts publiziert)"; exit 0; } - -for tag in "${missing[@]}"; do - echo - echo "── $tag ─────────────────────────────────" - git switch -q --detach "$tag" - CI=true pnpm install --frozen-lockfile >/dev/null - pnpm build >/dev/null - v=$(jq -r .version package.json) - [ "$v" = "${tag#v}" ] || { echo "package.json sagt $v, Tag sagt ${tag#v} — übersprungen."; continue; } - - if [ "$tag" = "$newest" ]; then - npm publish --no-provenance - else - # Unter einem Wegwerf-Tag, damit `latest` nicht auf eine alte Version faellt. - npm publish --no-provenance --tag backfill - fi -done - -echo -npm dist-tag ls "$PKG" | sed 's/^/ /' -if npm dist-tag ls "$PKG" | grep -q '^backfill:'; then - npm dist-tag rm "$PKG" backfill - echo "Wegwerf-Tag 'backfill' entfernt." -fi -echo -echo "npm steht jetzt auf: $(npm view "$PKG" version)" From 14db690497d0752edb734b4d1463c040ec0a1cf5 Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Tue, 1 Sep 2026 19:10:47 +0200 Subject: [PATCH 3/3] fix(ci): pin the reusable workflow to a full commit hash CodeQL flagged both publish jobs: a seven-character hash is not immutable enough to be a pin, because an abbreviation can grow ambiguous as the repository does. The full 40 characters cannot. --- .github/workflows/release-please.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b7734c5..09ee868 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -102,7 +102,7 @@ jobs: # `build-script` is left at its default: this repo's `build` writes the npm # artifact itself. coverage-report needs `build:npm` there only because its # `build` produces an Action bundle instead. - uses: kirchDev/workflows/.github/workflows/_publish-npm.yml@c7bd7b9 # unreleased — replace with the tag once workflows cuts v0.1.0 + uses: kirchDev/workflows/.github/workflows/_publish-npm.yml@c7bd7b92c046bedcb164fbdc759da975642581c2 # unreleased — replace with the tag once workflows cuts v0.1.0 with: tag-name: ${{ needs.release-please.outputs.tag-name }} @@ -122,6 +122,6 @@ jobs: permissions: contents: read id-token: write - uses: kirchDev/workflows/.github/workflows/_publish-npm.yml@c7bd7b9 # unreleased — replace with the tag once workflows cuts v0.1.0 + uses: kirchDev/workflows/.github/workflows/_publish-npm.yml@c7bd7b92c046bedcb164fbdc759da975642581c2 # unreleased — replace with the tag once workflows cuts v0.1.0 with: prerelease: true