Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -100,3 +114,50 @@ 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 "$PACKAGE_NAME" build
env:
PACKAGE_NAME: ${{ steps.pkg.outputs.name }}
- name: Publish ${{ inputs.release_tag }}
run: pnpm --filter "$PACKAGE_NAME" publish --provenance --access public --no-git-checks
env:
PACKAGE_NAME: ${{ steps.pkg.outputs.name }}
84 changes: 60 additions & 24 deletions packages/release-please-ai/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,30 @@ async function main(): Promise<void> {
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 {
Expand All @@ -56,40 +67,65 @@ interface ReleasePleaseManifest {

export async function runReleasePlease(
loadManifest: () => Promise<ReleasePleaseManifest>,
): 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`,
`${r.path}--tag_name=${r.tagName}`,
`${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`,
);
}

Expand Down
69 changes: 69 additions & 0 deletions packages/release-please-ai/test/unit/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});