From ec010e3eec7f607be10e0991b50ede11b02dc2b4 Mon Sep 17 00:00:00 2001 From: Ozan Dogrultan Date: Sun, 8 Nov 2026 12:22:31 +0100 Subject: [PATCH 1/2] ci(release): publish from a separate job so failed publishes can be re-run The cut job now only bumps, tags and pushes. A shared publish job runs for tag pushes and after a cut, so Re-run failed jobs retries the existing tag. Aligns release.yml across the opencode-* repos. --- .github/workflows/release.yml | 147 +++++++++++++++++----------------- RELEASING.md | 2 + 2 files changed, 74 insertions(+), 75 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ac1fe5..977a58c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,76 +20,12 @@ concurrency: cancel-in-progress: false jobs: - tagged: - name: publish tagged release - if: github.event_name == 'push' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 - with: - # Trusted publishing needs npm >= 11.5.1 (newer than Node 20 ships). - node-version: 24 - registry-url: "https://registry.npmjs.org" - - run: bun install --frozen-lockfile - - run: bun run test - - run: bun run typecheck - - name: Check whether the version is already published - id: published - run: | - version="${GITHUB_REF_NAME#v}" - pkg_version=$(node -p "require('./package.json').version") - if [ "$version" != "$pkg_version" ]; then - echo "::error::Tag version ($version) does not match package.json version ($pkg_version)." - exit 1 - fi - pkg=$(node -p "require('./package.json').name") - if npm view "$pkg@$version" version >/dev/null 2>&1; then - echo "exists=1" >> "$GITHUB_OUTPUT" - echo "::notice::$pkg@$version is already on npm; skipping publish." - else - echo "exists=0" >> "$GITHUB_OUTPUT" - fi - - name: Publish to npm - if: steps.published.outputs.exists == '0' - # npm (not bun) because trusted publishing authenticates with the OIDC - # id-token and attaches provenance automatically (package Trusted - # Publisher settings). No token is stored in the repo. - run: npm publish --provenance --access public - - name: Prepare release notes - id: notes - run: | - tag="${GITHUB_REF_NAME}" - if bash scripts/changelog.sh notes "$tag" > "$RUNNER_TEMP/notes.md"; then - echo "have=1" >> "$GITHUB_OUTPUT" - echo "::notice::using the CHANGELOG.md section for $tag" - else - echo "have=0" >> "$GITHUB_OUTPUT" - echo "::warning::CHANGELOG.md has no usable section for $tag; falling back to generated notes" - fi - - name: Create GitHub release - env: - GH_TOKEN: ${{ github.token }} - run: | - tag="${GITHUB_REF_NAME}" - have="${{ steps.notes.outputs.have }}" - if gh release view "$tag" >/dev/null 2>&1; then - if [ "$have" = "1" ]; then - gh release edit "$tag" --title "$tag" --notes-file "$RUNNER_TEMP/notes.md" - else - gh release edit "$tag" --title "$tag" - fi - elif [ "$have" = "1" ]; then - gh release create "$tag" --title "$tag" --notes-file "$RUNNER_TEMP/notes.md" --verify-tag - else - gh release create "$tag" --title "$tag" --generate-notes --verify-tag - fi - cut: - name: cut, tag, publish + name: cut and tag if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + outputs: + tag: ${{ steps.version.outputs.tag }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -99,7 +35,6 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: 24 - registry-url: "https://registry.npmjs.org" - run: bun install --frozen-lockfile - run: bun run test - run: bun run typecheck @@ -135,17 +70,79 @@ jobs: echo "tag=$version" >> "$GITHUB_OUTPUT" - name: Push commit and tag if: steps.draft.outputs.ok == '1' - # Tags pushed with GITHUB_TOKEN do not re-trigger this workflow, so the - # publish below is the only one for this release. + # Tags pushed with GITHUB_TOKEN do not trigger this workflow again; the + # publish job below picks the tag up from this job's output instead. run: git push --follow-tags + + publish: + name: publish release + needs: cut + # Runs for a pushed tag (cut is skipped) or after a cut that produced a tag. + # A failed publish is retried with "Re-run failed jobs" without re-cutting. + if: >- + !cancelled() && (github.event_name == 'push' || + (needs.cut.result == 'success' && needs.cut.outputs.tag != '')) + runs-on: ubuntu-latest + env: + TAG: ${{ needs.cut.outputs.tag || github.ref_name }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ env.TAG }} + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + # Trusted publishing needs npm >= 11.5.1 (newer than Node 20 ships). + node-version: 24 + registry-url: "https://registry.npmjs.org" + - run: bun install --frozen-lockfile + - run: bun run test + - run: bun run typecheck + - name: Check whether the version is already published + id: published + run: | + version="${TAG#v}" + pkg_version=$(node -p "require('./package.json').version") + if [ "$version" != "$pkg_version" ]; then + echo "::error::Tag version ($version) does not match package.json version ($pkg_version)." + exit 1 + fi + pkg=$(node -p "require('./package.json').name") + if npm view "$pkg@$version" version >/dev/null 2>&1; then + echo "exists=1" >> "$GITHUB_OUTPUT" + echo "::notice::$pkg@$version is already on npm; skipping publish." + else + echo "exists=0" >> "$GITHUB_OUTPUT" + fi - name: Publish to npm - if: steps.draft.outputs.ok == '1' + if: steps.published.outputs.exists == '0' + # npm (not bun) because trusted publishing authenticates with the OIDC + # id-token and attaches provenance automatically (package Trusted + # Publisher settings). No token is stored in the repo. run: npm publish --provenance --access public + - name: Prepare release notes + id: notes + run: | + if bash scripts/changelog.sh notes "$TAG" > "$RUNNER_TEMP/notes.md"; then + echo "have=1" >> "$GITHUB_OUTPUT" + echo "::notice::using the CHANGELOG.md section for $TAG" + else + echo "have=0" >> "$GITHUB_OUTPUT" + echo "::warning::CHANGELOG.md has no usable section for $TAG; falling back to generated notes" + fi - name: Create GitHub release - if: steps.draft.outputs.ok == '1' env: GH_TOKEN: ${{ github.token }} run: | - tag="${{ steps.version.outputs.tag }}" - bash scripts/changelog.sh notes "$tag" > "$RUNNER_TEMP/notes.md" - gh release create "$tag" --title "$tag" --notes-file "$RUNNER_TEMP/notes.md" --verify-tag + have="${{ steps.notes.outputs.have }}" + if gh release view "$TAG" >/dev/null 2>&1; then + if [ "$have" = "1" ]; then + gh release edit "$TAG" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md" + else + gh release edit "$TAG" --title "$TAG" + fi + elif [ "$have" = "1" ]; then + gh release create "$TAG" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md" --verify-tag + else + gh release create "$TAG" --title "$TAG" --generate-notes --verify-tag + fi diff --git a/RELEASING.md b/RELEASING.md index 2370773..79c0bd2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -21,6 +21,8 @@ That workflow checks out `main`, drafts notable commits, bumps the version, writes the dated changelog section, commits and tags it, pushes, publishes to npm, and cuts the GitHub release using that section as its notes. It does nothing when there are no notable commits since the last release. +If publishing fails after the tag is pushed, use **Re-run failed jobs**: the +publish job retries the existing tag without cutting a new version. Do this from a green `main` — it releases whatever is there. From e2f40fc0e66a20508f1089098e6a80211357c474 Mon Sep 17 00:00:00 2001 From: Ozan Dogrultan Date: Sun, 8 Nov 2026 12:40:17 +0100 Subject: [PATCH 2/2] ci(release): scope permissions per job and harden checkout and push cut gets contents: write only; publish adds id-token: write and checks out refs/tags/ without persisted credentials. The tag push is atomic and expression values reach run blocks through env. --- .github/workflows/release.yml | 24 ++++++++++++++---------- RELEASING.md | 5 +++-- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 977a58c..e9c739d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,10 +11,6 @@ on: options: [patch, minor, major] default: patch -permissions: - contents: write - id-token: write - concurrency: group: release-cut cancel-in-progress: false @@ -24,6 +20,8 @@ jobs: name: cut and tag if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + permissions: + contents: write outputs: tag: ${{ steps.version.outputs.tag }} steps: @@ -55,8 +53,10 @@ jobs: - name: Bump version, write the changelog and tag if: steps.draft.outputs.ok == '1' id: version + env: + BUMP: ${{ inputs.bump }} run: | - version="$(npm version "${{ inputs.bump }}" --no-git-tag-version | tail -n 1)" + version="$(npm version "$BUMP" --no-git-tag-version | tail -n 1)" case "$version" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "unexpected npm version output: $version"; exit 1 ;; @@ -72,7 +72,7 @@ jobs: if: steps.draft.outputs.ok == '1' # Tags pushed with GITHUB_TOKEN do not trigger this workflow again; the # publish job below picks the tag up from this job's output instead. - run: git push --follow-tags + run: git push --atomic --follow-tags publish: name: publish release @@ -83,12 +83,16 @@ jobs: !cancelled() && (github.event_name == 'push' || (needs.cut.result == 'success' && needs.cut.outputs.tag != '')) runs-on: ubuntu-latest + permissions: + contents: write + id-token: write env: TAG: ${{ needs.cut.outputs.tag || github.ref_name }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ env.TAG }} + ref: refs/tags/${{ env.TAG }} + persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: @@ -133,15 +137,15 @@ jobs: - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} + HAVE: ${{ steps.notes.outputs.have }} run: | - have="${{ steps.notes.outputs.have }}" if gh release view "$TAG" >/dev/null 2>&1; then - if [ "$have" = "1" ]; then + if [ "$HAVE" = "1" ]; then gh release edit "$TAG" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md" else gh release edit "$TAG" --title "$TAG" fi - elif [ "$have" = "1" ]; then + elif [ "$HAVE" = "1" ]; then gh release create "$TAG" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md" --verify-tag else gh release create "$TAG" --title "$TAG" --generate-notes --verify-tag diff --git a/RELEASING.md b/RELEASING.md index 79c0bd2..805aef7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,7 +1,8 @@ # Releasing -[`.github/workflows/release.yml`](.github/workflows/release.yml) does the whole -job: run tests, publish to npm with provenance, and create the GitHub release. +[`.github/workflows/release.yml`](.github/workflows/release.yml) runs in two +jobs: `cut` (manual runs only) bumps, tags and pushes; `publish` runs tests, +publishes to npm with provenance and creates the GitHub release. There are two ways to trigger it. Development uses [bun](https://bun.sh) (`bun install`, `bun run test`). Publishing