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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"typecheck:packages": "pnpm -r --filter \"@daypicker/*\" typecheck && pnpm --filter react-day-picker typecheck",
"check:versions": "node ./scripts/check-package-versions.mjs",
"pack:dry-run": "node ./scripts/npm-pack-dry-run.mjs",
"release:ci": "pnpm exec tsx ./scripts/release-ci.ts",
"typecheck-watch": "tsc --project ./tsconfig.json --noEmit --watch"
},
"devDependencies": {
Expand Down
159 changes: 159 additions & 0 deletions scripts/create-github-release.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
type CreateGitHubReleaseScriptModule = typeof import("./create-github-release");

let createGitHubRelease: CreateGitHubReleaseScriptModule["createGitHubRelease"];
let releaseContext: ReturnType<typeof createReleaseContext>;
let createReleaseFetchMock: jest.MockedFunction<typeof fetch>;
const originalCreateReleaseFetch = global.fetch;

beforeAll(async function loadModule() {
({ createGitHubRelease } = await import("./create-github-release"));
});

beforeEach(function setupTestState() {
releaseContext = createReleaseContext();
createReleaseFetchMock = jest.fn() as jest.MockedFunction<typeof fetch>;
global.fetch = createReleaseFetchMock;
});

afterEach(function restoreFetch() {
global.fetch = originalCreateReleaseFetch;
});

function createReleaseContext(
overrides: Partial<{
repository: string;
token: string;
commitSha: string;
packageVersion: string;
}> = {},
) {
return {
repository: "gpbl/react-day-picker",
token: "test-token",
commitSha: "abc123",
packageVersion: "10.0.0-next.1",
...overrides,
};
}

function createReleasePayload(
overrides: Partial<{
html_url: string;
}> = {},
) {
return {
html_url:
"https://github.com/gpbl/react-day-picker/releases/tag/v10.0.0-next.1",
...overrides,
};
}

function createGitHubFetchResponse(
overrides: Partial<{
json: () => Promise<unknown>;
ok: boolean;
status: number;
}> = {},
) {
return {
ok: true,
status: 200,
json: async () => createReleasePayload(),
...overrides,
} as Response;
}

describe("createGitHubRelease", function describeCreateGitHubRelease() {
test("it reuses an existing release for the repo version", async function testExistingRelease() {
createReleaseFetchMock.mockResolvedValueOnce(createGitHubFetchResponse());

await expect(createGitHubRelease(releaseContext)).resolves.toEqual({
created: false,
release: createReleasePayload(),
tag: "v10.0.0-next.1",
});

expect(createReleaseFetchMock).toHaveBeenCalledTimes(1);
});

test("it creates the release when the tag does not exist yet", async function testCreateReleaseOn404() {
createReleaseFetchMock
.mockResolvedValueOnce(
createGitHubFetchResponse({
ok: false,
status: 404,
}),
)
.mockResolvedValueOnce(
createGitHubFetchResponse({
json: async () => createReleasePayload(),
}),
);

await expect(createGitHubRelease(releaseContext)).resolves.toEqual({
created: true,
release: createReleasePayload(),
tag: "v10.0.0-next.1",
});

expect(createReleaseFetchMock).toHaveBeenNthCalledWith(
2,
"https://api.github.com/repos/gpbl/react-day-picker/releases",
expect.objectContaining({
method: "POST",
}),
);
});

test("it creates stable releases without the prerelease flag", async function testStableReleaseFlag() {
releaseContext.packageVersion = "10.0.0";
createReleaseFetchMock
.mockResolvedValueOnce(
createGitHubFetchResponse({
ok: false,
status: 404,
}),
)
.mockResolvedValueOnce(createGitHubFetchResponse());

await createGitHubRelease(releaseContext);

const createRequest = createReleaseFetchMock.mock.calls[1]?.[1];
const createBody =
createRequest &&
typeof createRequest === "object" &&
"body" in createRequest &&
typeof createRequest.body === "string"
? JSON.parse(createRequest.body)
: undefined;

expect(createBody).toMatchObject({
name: "v10.0.0",
prerelease: false,
tag_name: "v10.0.0",
target_commitish: "abc123",
});
});

test("it rejects invalid repository values", async function testInvalidRepository() {
releaseContext.repository = "react-day-picker";

await expect(createGitHubRelease(releaseContext)).rejects.toThrow(
"Invalid GITHUB_REPOSITORY value: react-day-picker",
);
expect(createReleaseFetchMock).not.toHaveBeenCalled();
});

test("it rethrows unexpected lookup failures", async function testUnexpectedLookupFailure() {
createReleaseFetchMock.mockResolvedValueOnce(
createGitHubFetchResponse({
ok: false,
status: 500,
}),
);

await expect(createGitHubRelease(releaseContext)).rejects.toThrow(
"Could not read GitHub Release v10.0.0-next.1 (HTTP 500).",
);
});
});
143 changes: 143 additions & 0 deletions scripts/create-github-release.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Reads the GitHub Release for the version tag if it already exists.
*/
async function fetchReleaseByTag(request: {
owner: string;
repo: string;
tag: string;
token: string;
}): Promise<{
html_url: string;
}> {
const { owner, repo, tag, token } = request;
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`,
{
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
// Pin the REST API version so release automation does not drift with
// GitHub's default behavior over time.
"X-GitHub-Api-Version": "2022-11-28",
},
},
);

if (response.status === 404) {
throw Object.assign(new Error(`GitHub Release ${tag} was not found.`), {
status: 404,
});
}

if (!response.ok) {
throw new Error(
`Could not read GitHub Release ${tag} (HTTP ${response.status}).`,
);
}

return response.json() as Promise<{
html_url: string;
}>;
}

/**
* Creates the repo-level GitHub Release after npm publish succeeds.
*/
async function createRelease(request: {
owner: string;
repo: string;
tag: string;
token: string;
commitSha: string;
prerelease: boolean;
}): Promise<{
html_url: string;
}> {
const { owner, repo, tag, token, commitSha, prerelease } = request;
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/releases`,
{
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
// Pin the REST API version so release automation does not drift with
// GitHub's default behavior over time.
"X-GitHub-Api-Version": "2022-11-28",
},
body: JSON.stringify({
tag_name: tag,
target_commitish: commitSha,
name: tag,
draft: false,
prerelease,
generate_release_notes: true,
}),
},
);

if (!response.ok) {
throw new Error(
`Could not create GitHub Release ${tag} (HTTP ${response.status}).`,
);
}

return response.json() as Promise<{
html_url: string;
}>;
}

/**
* Ensures the repo has a single GitHub Release for the published version.
*
* The helper is intentionally idempotent so reruns can recover from a partial
* publish where npm succeeded but GitHub Release creation did not.
*/
export async function createGitHubRelease(context: {
repository: string;
token: string;
commitSha: string;
packageVersion: string;
}): Promise<{
created: boolean;
release: {
html_url: string;
};
tag: string;
}> {
const [owner, repo] = context.repository.split("/");
if (!owner || !repo) {
throw new Error(`Invalid GITHUB_REPOSITORY value: ${context.repository}`);
}

const tag = `v${context.packageVersion}`;
const isPrereleaseVersion = context.packageVersion.includes("-next");

try {
const existingRelease = await fetchReleaseByTag({
owner,
repo,
tag,
token: context.token,
});
return { created: false, release: existingRelease, tag };
} catch (error) {
if (
!(error instanceof Error && "status" in error && error.status === 404)
) {
throw error;
}
}

const createdRelease = await createRelease({
owner,
repo,
tag,
token: context.token,
commitSha: context.commitSha,
prerelease: isPrereleaseVersion,
});

return { created: true, release: createdRelease, tag };
}
Loading
Loading