From 5ae653e63eef3d411d97914e6d4f17859357992a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 18 Aug 2026 15:29:02 +0200 Subject: [PATCH 1/2] fix: publish releases even when release PR refresh fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox-v0.4.0 release was tagged on GitHub but never published to npm: the release run crashed while refreshing sibling release PR branches (a ref-update race), after createReleases() had tagged the release but before the job emitted its release_created outputs. The re-run then saw the release as already tagged, emitted no outputs, and skipped every publish job — losing the npm publish permanently. Three layers of fix: - cli.ts now emits release outputs immediately after createReleases() and treats the release-PR refresh and check re-runs as non-fatal housekeeping, surfaced as a workflow warning annotation. The next push to main retries the refresh anyway. - The publish jobs' conditions add !cancelled() so outputs written before a job failure still trigger the publishes. - A workflow_dispatch publish-recovery job republishes an already-tagged release whose npm publish never ran (guarded against tag/manifest version mismatch and already-published versions), so this failure mode no longer requires deleting and recreating releases to recover. Change-Id: I669756fe5585d45746fd00e1c6bf0fb86d5b98f2 Co-Authored-By: Claude Fable 5 Signed-off-by: Thomas Kosiewski --- .github/workflows/release-please.yml | 63 +++++++++++++- packages/release-please-ai/src/cli.ts | 84 +++++++++++++------ .../release-please-ai/test/unit/cli.test.ts | 69 +++++++++++++++ 3 files changed, 189 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 0b5cdec..af080fb 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -3,6 +3,15 @@ name: release-please on: push: branches: [main] + # Recovery lever for a release that was tagged on GitHub but never reached + # npm (e.g. the release run died between tagging and emitting its outputs). + # Trusted publishing authorizes it because it runs from this same workflow file. + workflow_dispatch: + inputs: + release_tag: + description: "Existing release tag to publish to npm (e.g. sandbox-v0.4.0)" + required: true + type: string # Only one release run at a time; never cancel an in-progress publish. concurrency: @@ -13,6 +22,7 @@ permissions: {} jobs: release-please: + if: ${{ github.event_name == 'push' }} runs-on: ubuntu-latest permissions: actions: write # restart checks after GITHUB_TOKEN updates release PR branches @@ -47,9 +57,13 @@ jobs: # Publish jobs authenticate to npm via OIDC trusted publishing (no NPM_TOKEN). # Requires a trusted publisher configured per package on npmjs.com pointing at # this repo + workflow file. Provenance is attested via the OIDC id-token. + # The publish conditions use !cancelled() so an emitted release still publishes + # even if the release-please job later failed on housekeeping: its outputs are + # written the moment releases are tagged, and a tagged release only publishes + # from this run (re-runs see it as already tagged and skip it). publish-sandbox: needs: release-please - if: ${{ needs.release-please.outputs.sandbox_released == 'true' }} + if: ${{ !cancelled() && needs.release-please.outputs.sandbox_released == 'true' }} runs-on: ubuntu-latest permissions: contents: read # checkout only @@ -67,7 +81,7 @@ jobs: publish-agent: needs: release-please - if: ${{ needs.release-please.outputs.agent_released == 'true' }} + if: ${{ !cancelled() && needs.release-please.outputs.agent_released == 'true' }} runs-on: ubuntu-latest permissions: contents: read # checkout only @@ -85,7 +99,7 @@ jobs: publish-provider: needs: release-please - if: ${{ needs.release-please.outputs.provider_released == 'true' }} + if: ${{ !cancelled() && needs.release-please.outputs.provider_released == 'true' }} runs-on: ubuntu-latest permissions: contents: read # checkout only @@ -100,3 +114,46 @@ jobs: - run: pnpm --filter @coder/ai-sdk-provider build - name: Publish @coder/ai-sdk-provider run: pnpm --filter @coder/ai-sdk-provider publish --provenance --access public --no-git-checks + + # Manual recovery (workflow_dispatch): publish an already-tagged release whose + # npm publish never ran. Builds the tag's checkout and refuses to run if the + # tag doesn't match the package.json version or the version is already on npm. + publish-recovery: + if: ${{ github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + permissions: + contents: read # checkout only + id-token: write # OIDC: trusted-publishing auth + provenance + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.release_tag }} + persist-credentials: false + - name: Install node and pnpm via mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + - name: Resolve package from tag + id: pkg + env: + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + case "$RELEASE_TAG" in + sandbox-v*) name="@coder/ai-sdk-sandbox" path="packages/sandbox" ;; + agent-v*) name="@coder/ai-sdk-agent" path="packages/agent" ;; + provider-v*) name="@coder/ai-sdk-provider" path="packages/provider" ;; + *) echo "unrecognized release tag: $RELEASE_TAG" >&2; exit 1 ;; + esac + version="${RELEASE_TAG#*-v}" + manifest_version="$(jq -r .version "$path/package.json")" + if [ "$manifest_version" != "$version" ]; then + echo "tag version $version does not match $path/package.json version $manifest_version" >&2 + exit 1 + fi + if npm view "$name@$version" version >/dev/null 2>&1; then + echo "$name@$version is already published" >&2 + exit 1 + fi + echo "name=$name" >> "$GITHUB_OUTPUT" + - run: pnpm install --frozen-lockfile + - run: pnpm --filter ${{ steps.pkg.outputs.name }} build + - name: Publish ${{ inputs.release_tag }} + run: pnpm --filter ${{ steps.pkg.outputs.name }} publish --provenance --access public --no-git-checks diff --git a/packages/release-please-ai/src/cli.ts b/packages/release-please-ai/src/cli.ts index e185b98..28ce9ed 100644 --- a/packages/release-please-ai/src/cli.ts +++ b/packages/release-please-ai/src/cli.ts @@ -34,19 +34,30 @@ async function main(): Promise { registerAiChangelogNotes(); const github = await GitHub.create({ owner, repo, token, defaultBranch: targetBranch }); - const result = await runReleasePlease(() => - Manifest.fromManifest(github, targetBranch, configFile, manifestFile), + const result = await runReleasePlease( + () => Manifest.fromManifest(github, targetBranch, configFile, manifestFile), + emitReleaseOutputs, ); - const rerunCount = await rerunActionRequiredChecks( - github.getGitHubApi().octokit, - { owner, repo }, - result.pullRequests, - ); - if (rerunCount > 0) { - process.stderr.write(`release-please: restarted ${rerunCount} release PR check run(s)\n`); + emitPullRequestOutputs(result.pullRequests.length); + if (result.pullRequestError) { + warnNonFatal( + "Refreshing release PRs failed; releases were already tagged and their outputs emitted. The next push to main retries the refresh.", + result.pullRequestError, + ); + return; + } + try { + const rerunCount = await rerunActionRequiredChecks( + github.getGitHubApi().octokit, + { owner, repo }, + result.pullRequests, + ); + if (rerunCount > 0) { + process.stderr.write(`release-please: restarted ${rerunCount} release PR check run(s)\n`); + } + } catch (error) { + warnNonFatal("Restarting release PR check runs failed.", error); } - - emitOutputs(result.pullRequests.length, result.releases); } interface ReleasePleaseManifest { @@ -56,25 +67,37 @@ interface ReleasePleaseManifest { export async function runReleasePlease( loadManifest: () => Promise, -): Promise<{ pullRequests: PullRequest[]; releases: CreatedRelease[] }> { + onReleasesCreated?: (releases: CreatedRelease[]) => void, +): Promise<{ + pullRequests: PullRequest[]; + releases: CreatedRelease[]; + pullRequestError?: unknown; +}> { const releaseManifest = await loadManifest(); const releases = (await releaseManifest.createReleases()).filter( (release): release is CreatedRelease => Boolean(release), ); + // Report releases before touching release PRs: a created release can only be + // published from this run's outputs (re-runs see it as already tagged), so a + // PR-refresh failure past this point must not swallow them. + onReleasesCreated?.(releases); - // Reload after tagging merged releases. Otherwise createPullRequests sees the - // just-merged release PR as untagged and aborts before refreshing sibling PRs. - const pullRequestManifest = await loadManifest(); - const pullRequests = (await pullRequestManifest.createPullRequests()).filter( - (pullRequest): pullRequest is PullRequest => Boolean(pullRequest), - ); - - return { pullRequests, releases }; + try { + // Reload after tagging merged releases. Otherwise createPullRequests sees the + // just-merged release PR as untagged and aborts before refreshing sibling PRs. + const pullRequestManifest = await loadManifest(); + const pullRequests = (await pullRequestManifest.createPullRequests()).filter( + (pullRequest): pullRequest is PullRequest => Boolean(pullRequest), + ); + return { pullRequests, releases }; + } catch (error) { + return { pullRequests: [], releases, pullRequestError: error }; + } } /** Write GitHub Actions step outputs (mirrors googleapis/release-please-action). */ -function emitOutputs(prCount: number, releases: CreatedRelease[]): void { - const lines = [`releases_created=${releases.length > 0}`, `prs_created=${prCount > 0}`]; +function emitReleaseOutputs(releases: CreatedRelease[]): void { + const lines = [`releases_created=${releases.length > 0}`]; for (const r of releases) { lines.push( `${r.path}--release_created=true`, @@ -82,14 +105,27 @@ function emitOutputs(prCount: number, releases: CreatedRelease[]): void { `${r.path}--version=${r.version}`, ); } - const text = `${lines.join("\n")}\n`; + writeOutputs(`release-please: ${releases.length} release(s) created`, lines); +} + +function emitPullRequestOutputs(prCount: number): void { + writeOutputs(`release-please: ${prCount} PR(s) opened/updated`, [`prs_created=${prCount > 0}`]); +} +function writeOutputs(summary: string, lines: string[]): void { + const text = `${lines.join("\n")}\n`; const outputFile = process.env.GITHUB_OUTPUT; if (outputFile) { appendFileSync(outputFile, text); } + process.stderr.write(`${summary}\n${text}`); +} + +/** Surface a housekeeping failure as a workflow warning annotation without failing the job. */ +function warnNonFatal(message: string, error: unknown): void { + process.stdout.write(`::warning title=release-please::${message}\n`); process.stderr.write( - `release-please: ${releases.length} release(s) created, ${prCount} PR(s) opened/updated\n${text}`, + `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, ); } diff --git a/packages/release-please-ai/test/unit/cli.test.ts b/packages/release-please-ai/test/unit/cli.test.ts index 706a3b2..fed17c6 100644 --- a/packages/release-please-ai/test/unit/cli.test.ts +++ b/packages/release-please-ai/test/unit/cli.test.ts @@ -35,4 +35,73 @@ describe("runReleasePlease", () => { expect(loadManifest).toHaveBeenCalledTimes(2); expect(result).toEqual({ pullRequests: [{ number: 27 }], releases: [release] }); }); + + it("reports created releases before refreshing release pull requests", async () => { + const calls: string[] = []; + const release = { path: "packages/sandbox" } as CreatedRelease; + const loadManifest = vi.fn(async () => ({ + createReleases: async () => [release, undefined], + createPullRequests: async () => { + calls.push("createPullRequests"); + return []; + }, + })); + const onReleasesCreated = vi.fn((releases: CreatedRelease[]) => { + calls.push(`onReleasesCreated:${releases.length}`); + }); + + await runReleasePlease(loadManifest, onReleasesCreated); + + expect(calls).toEqual(["onReleasesCreated:1", "createPullRequests"]); + expect(onReleasesCreated).toHaveBeenCalledWith([release]); + }); + + it("returns created releases when refreshing release pull requests fails", async () => { + const release = { path: "packages/sandbox" } as CreatedRelease; + const refreshError = new Error("Error updating ref"); + const loadManifest = vi + .fn() + .mockImplementationOnce(async () => ({ + createReleases: async () => [release], + createPullRequests: async () => [], + })) + .mockImplementationOnce(async () => ({ + createReleases: async () => [], + createPullRequests: async () => { + throw refreshError; + }, + })); + const onReleasesCreated = vi.fn(); + + const result = await runReleasePlease(loadManifest, onReleasesCreated); + + expect(onReleasesCreated).toHaveBeenCalledWith([release]); + expect(result).toEqual({ + pullRequests: [], + releases: [release], + pullRequestError: refreshError, + }); + }); + + it("returns created releases when reloading the manifest for pull requests fails", async () => { + const release = { path: "packages/agent" } as CreatedRelease; + const reloadError = new Error("manifest reload failed"); + const loadManifest = vi + .fn() + .mockImplementationOnce(async () => ({ + createReleases: async () => [release], + createPullRequests: async () => [], + })) + .mockImplementationOnce(async () => { + throw reloadError; + }); + + const result = await runReleasePlease(loadManifest); + + expect(result).toEqual({ + pullRequests: [], + releases: [release], + pullRequestError: reloadError, + }); + }); }); From 02e82fd02ae012e74db1d47fc161d34eae5464be Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 18 Aug 2026 15:35:26 +0200 Subject: [PATCH 2/2] fix: pass package filter via env in publish-recovery zizmor flags step-output expansion inside run blocks as template injection; route it through an env var like the other workflows. Change-Id: I7d395ab71db7a75fb7eccb9a191e96e75b32adca Co-Authored-By: Claude Fable 5 Signed-off-by: Thomas Kosiewski --- .github/workflows/release-please.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index af080fb..9628f41 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -154,6 +154,10 @@ jobs: fi echo "name=$name" >> "$GITHUB_OUTPUT" - run: pnpm install --frozen-lockfile - - run: pnpm --filter ${{ steps.pkg.outputs.name }} build + - run: pnpm --filter "$PACKAGE_NAME" build + env: + PACKAGE_NAME: ${{ steps.pkg.outputs.name }} - name: Publish ${{ inputs.release_tag }} - run: pnpm --filter ${{ steps.pkg.outputs.name }} publish --provenance --access public --no-git-checks + run: pnpm --filter "$PACKAGE_NAME" publish --provenance --access public --no-git-checks + env: + PACKAGE_NAME: ${{ steps.pkg.outputs.name }}