From 2915d0888c457de027b3cbe742047e328cdc6a6c Mon Sep 17 00:00:00 2001 From: Jacob Nussdorf Date: Mon, 3 Aug 2026 10:53:55 -0400 Subject: [PATCH 1/5] fix(ci): wire sc-governed-write-auth for security gate bypass semantic-release pushes version-bump commits directly to master; authenticate as sc-governed-write so the push is allowed once the org security gate is enabled on this repo. Co-authored-by: Cursor --- .github/workflows/release.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e8dadd..5d929d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,8 @@ on: permissions: id-token: write + contents: write + packages: read jobs: release: @@ -28,7 +30,14 @@ jobs: run: npm ci - name: Build run: make build + # Authenticate as sc-governed-write so the push bypasses the security gate. + # See https://eng-doc.soundcloud.org/security/security-gate/bypass-allowlist/ + - name: Authenticate for governed default-branch push + id: sc-governed-write + uses: soundcloud/security-tooling/.github/actions/sc-governed-write-auth@08ef0c295fb3692643771a8100a1e6649c50ac9a + with: + sc-governed-write-app-private-key: ${{ secrets.SC_GOVERNED_WRITE_APP_PRIVATE_KEY }} - name: Release env: - GH_TOKEN: ${{ secrets.CI_TOKEN }} + GH_TOKEN: ${{ steps.sc-governed-write.outputs.token }} run: npm run semantic-release From e23480dde631dae08b863686330299ee21269d51 Mon Sep 17 00:00:00 2001 From: Jacob Nussdorf Date: Mon, 3 Aug 2026 10:55:07 -0400 Subject: [PATCH 2/5] fix(ci): pin sc-governed-write-auth to current main SHA Co-authored-by: Cursor --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d929d6..ffb9693 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: # See https://eng-doc.soundcloud.org/security/security-gate/bypass-allowlist/ - name: Authenticate for governed default-branch push id: sc-governed-write - uses: soundcloud/security-tooling/.github/actions/sc-governed-write-auth@08ef0c295fb3692643771a8100a1e6649c50ac9a + uses: soundcloud/security-tooling/.github/actions/sc-governed-write-auth@2e4086b2fe93e68d074a8037d18f55a3f0ea78ce with: sc-governed-write-app-private-key: ${{ secrets.SC_GOVERNED_WRITE_APP_PRIVATE_KEY }} - name: Release From 1cab8a7827eec5f56906a95085bdb337cdb1091e Mon Sep 17 00:00:00 2001 From: Jacob Nussdorf Date: Wed, 19 Aug 2026 09:59:08 -0400 Subject: [PATCH 3/5] fix(ci): keep CI_TOKEN for semantic-release GitHub API calls sc-governed-write-auth configures git credentials for the default-branch push only. CI_TOKEN remains GH_TOKEN so @semantic-release/github can create releases, comment on issues/PRs, and open failure issues. Co-authored-by: Cursor --- .github/workflows/release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffb9693..201b433 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,9 @@ jobs: run: npm ci - name: Build run: make build - # Authenticate as sc-governed-write so the push bypasses the security gate. + # Configure git credentials as sc-governed-write so @semantic-release/git's + # push to master bypasses the security gate. Keep CI_TOKEN as GH_TOKEN for + # @semantic-release/github (releases, issue comments, failure issues). # See https://eng-doc.soundcloud.org/security/security-gate/bypass-allowlist/ - name: Authenticate for governed default-branch push id: sc-governed-write @@ -39,5 +41,5 @@ jobs: sc-governed-write-app-private-key: ${{ secrets.SC_GOVERNED_WRITE_APP_PRIVATE_KEY }} - name: Release env: - GH_TOKEN: ${{ steps.sc-governed-write.outputs.token }} + GH_TOKEN: ${{ secrets.CI_TOKEN }} run: npm run semantic-release From 5539b2e162214692b7e31435b715796b56c309cb Mon Sep 17 00:00:00 2001 From: Jacob Nussdorf Date: Wed, 19 Aug 2026 10:01:05 -0400 Subject: [PATCH 4/5] chore(ci): pin sc-governed-write-auth to main Use the composite action branch ref instead of a commit SHA so callers pick up security-tooling updates without manual bumps. Co-authored-by: Cursor --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 201b433..b17fc19 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: # See https://eng-doc.soundcloud.org/security/security-gate/bypass-allowlist/ - name: Authenticate for governed default-branch push id: sc-governed-write - uses: soundcloud/security-tooling/.github/actions/sc-governed-write-auth@2e4086b2fe93e68d074a8037d18f55a3f0ea78ce + uses: soundcloud/security-tooling/.github/actions/sc-governed-write-auth@main with: sc-governed-write-app-private-key: ${{ secrets.SC_GOVERNED_WRITE_APP_PRIVATE_KEY }} - name: Release From 3058d71f16602ff258a36fa2cb92878a84b2efa0 Mon Sep 17 00:00:00 2001 From: Jacob Nussdorf Date: Wed, 19 Aug 2026 10:10:39 -0400 Subject: [PATCH 5/5] fix(ci): scope sc-governed-write to default-branch push steps Split semantic-release into prep (CI_TOKEN for npm OIDC and analysis), governed git push, and GitHub release creation so sc-governed-write-auth only configures credentials for the master push. Co-authored-by: Cursor --- .github/workflows/release.yml | 21 ++++++++--- release.config.js | 69 ++++++++++++++++++++++------------- scripts/release-github.sh | 18 +++++++++ scripts/release-push.sh | 35 ++++++++++++++++++ 4 files changed, 111 insertions(+), 32 deletions(-) create mode 100755 scripts/release-github.sh create mode 100755 scripts/release-push.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b17fc19..8135d3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ jobs: uses: actions/checkout@v4 with: persist-credentials: false + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -30,16 +31,24 @@ jobs: run: npm ci - name: Build run: make build - # Configure git credentials as sc-governed-write so @semantic-release/git's - # push to master bypasses the security gate. Keep CI_TOKEN as GH_TOKEN for - # @semantic-release/github (releases, issue comments, failure issues). - # See https://eng-doc.soundcloud.org/security/security-gate/bypass-allowlist/ + - name: Prepare release (analyze, changelog, npm publish) + env: + SEMANTIC_RELEASE_PHASE: prep + GH_TOKEN: ${{ secrets.CI_TOKEN }} + run: npm run semantic-release + # Mint sc-governed-write only for the default-branch push; prep and GitHub release + # keep CI_TOKEN (npm OIDC provenance and @semantic-release/github parity). + # https://eng-doc.soundcloud.org/security/security-gate/bypass-allowlist/ - name: Authenticate for governed default-branch push id: sc-governed-write uses: soundcloud/security-tooling/.github/actions/sc-governed-write-auth@main with: sc-governed-write-app-private-key: ${{ secrets.SC_GOVERNED_WRITE_APP_PRIVATE_KEY }} - - name: Release + - name: Push release commit and tags to default branch + id: release-push + run: ./scripts/release-push.sh + - name: Create GitHub release + if: steps.release-push.outputs.released == 'true' env: GH_TOKEN: ${{ secrets.CI_TOKEN }} - run: npm run semantic-release + run: ./scripts/release-github.sh diff --git a/release.config.js b/release.config.js index 089b316..7d00ce3 100644 --- a/release.config.js +++ b/release.config.js @@ -1,35 +1,52 @@ const noteKeywords = ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING']; -module.exports = { - plugins: [ - [ - '@semantic-release/commit-analyzer', - { - preset: 'angular', - releaseRules: [ - { type: 'refactor', release: 'patch' }, - { type: 'revert', release: 'patch' }, - ], - parserOpts: { - noteKeywords, - }, - }, - ], - [ - '@semantic-release/release-notes-generator', - { - preset: 'angular', - parserOpts: { - noteKeywords, - }, - writerOpts: { - commitsSort: ['subject', 'scope'], - }, - }, +const commitAnalyzer = [ + '@semantic-release/commit-analyzer', + { + preset: 'angular', + releaseRules: [ + { type: 'refactor', release: 'patch' }, + { type: 'revert', release: 'patch' }, ], + parserOpts: { + noteKeywords, + }, + }, +]; + +const releaseNotesGenerator = [ + '@semantic-release/release-notes-generator', + { + preset: 'angular', + parserOpts: { + noteKeywords, + }, + writerOpts: { + commitsSort: ['subject', 'scope'], + }, + }, +]; + +const pluginsByPhase = { + prep: [ + commitAnalyzer, + releaseNotesGenerator, + ['@semantic-release/changelog'], + ['@semantic-release/npm'], + ], + all: [ + commitAnalyzer, + releaseNotesGenerator, ['@semantic-release/changelog'], ['@semantic-release/npm'], ['@semantic-release/git'], ['@semantic-release/github'], ], }; + +const phase = process.env.SEMANTIC_RELEASE_PHASE || 'all'; +const plugins = pluginsByPhase[phase] || pluginsByPhase.all; + +module.exports = { + plugins, +}; diff --git a/scripts/release-github.sh b/scripts/release-github.sh new file mode 100755 index 0000000..7afeba5 --- /dev/null +++ b/scripts/release-github.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION=$(node -p "require('./package.json').version") +TAG="v${VERSION}" + +if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub release ${TAG} already exists." + exit 0 +fi + +NOTES=$(awk -v version="$VERSION" ' + $0 ~ "^## \\[" version "\\]" { capture=1; next } + capture && /^# / { exit } + capture { print } +' CHANGELOG.md) + +gh release create "$TAG" --notes "$NOTES" diff --git a/scripts/release-push.sh b/scripts/release-push.sh new file mode 100755 index 0000000..c25a6ea --- /dev/null +++ b/scripts/release-push.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +assets=(CHANGELOG.md package.json package-lock.json) +existing=() +for file in "${assets[@]}"; do + if [ -e "$file" ]; then + existing+=("$file") + fi +done + +if [ ${#existing[@]} -eq 0 ]; then + echo "No release assets found." + echo "released=false" >> "${GITHUB_OUTPUT:-/dev/null}" + exit 0 +fi + +if git diff --quiet HEAD -- "${existing[@]}" 2>/dev/null && \ + [ -z "$(git status --porcelain -- "${existing[@]}")" ]; then + echo "No release changes to push." + echo "released=false" >> "${GITHUB_OUTPUT:-/dev/null}" + exit 0 +fi + +VERSION=$(node -p "require('./package.json').version") +TAG="v${VERSION}" +MESSAGE="chore(release): ${VERSION}" + +git add "${existing[@]}" +git commit -m "${MESSAGE}" +git tag -a "${TAG}" -m "${MESSAGE}" +git push origin HEAD --tags + +echo "released=true" >> "${GITHUB_OUTPUT:-/dev/null}" +echo "version=${VERSION}" >> "${GITHUB_OUTPUT:-/dev/null}"