Skip to content
Draft
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
57 changes: 35 additions & 22 deletions scripts/release.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { Buffer } from "node:buffer";
import path from "node:path";
import { exec } from "@actions/exec";
import { exec, getExecOutput } from "@actions/exec";
import major from "semver/functions/major.js";
import prerelease from "semver/functions/prerelease.js";
import pkgJson from "../package.json" with { type: "json" };

const tag = `v${pkgJson.version}`;
const releaseLine = `v${pkgJson.version.split(".")[0]}`;
const isPrerelease = pkgJson.version.includes("-");
const prereleaseTag = prerelease(pkgJson.version)?.[0];
const releaseLine = `v${major(pkgJson.version)}${
prereleaseTag === undefined ? "" : `-${prereleaseTag}`
}`;
const githubToken = process.env.GITHUB_TOKEN;
if (!githubToken) {
throw new Error("GITHUB_TOKEN is required");
Expand All @@ -21,27 +25,36 @@ const gitEnv = {
process.chdir(path.join(import.meta.dirname, ".."));

await exec("git", ["checkout", "--detach"]);
// Stable timestamps make retries produce the same commit when dist is unchanged.
const { stdout } = await getExecOutput("git", [
"show",
"--no-patch",
"--format=%cI",
"HEAD",
]);
const commitDate = stdout.trim();
await exec("git", ["add", "--force", "dist"]);
await exec("git", ["commit", "-m", tag]);
await exec("git", ["commit", "-m", tag], {
env: {
...process.env,
GIT_AUTHOR_DATE: commitDate,
GIT_COMMITTER_DATE: commitDate,
},
});

await exec("changeset", ["git-tag"]);

if (isPrerelease) {
await exec("git", ["push", "origin", `refs/tags/${tag}`], {
await exec(
"git",
[
"push",
"--force",
// The action pushes tags through the API; override any push.followTags config.
"--no-follow-tags",
"origin",
`HEAD:refs/heads/${releaseLine}`,
],
{
env: gitEnv,
});
} else {
await exec(
"git",
[
"push",
"--force",
"--follow-tags",
"origin",
`HEAD:refs/heads/${releaseLine}`,
],
{
env: gitEnv,
},
);
}
},
);
69 changes: 61 additions & 8 deletions src/github.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import assert from "node:assert/strict";
import { Buffer } from "node:buffer";
import * as core from "@actions/core";
import { exec, getExecOutput } from "@actions/exec";
Expand Down Expand Up @@ -67,6 +68,19 @@ function getHttpUrl(remoteUrl: string): string | undefined {
}
}

function isAlreadyExistingRefError(error: unknown) {
return (
typeof error === "object" &&
error !== null &&
"status" in error &&
"message" in error &&
typeof error.status === "number" &&
typeof error.message === "string" &&
error.status === 422 &&
error.message.includes("Reference already exists")
);
}

export class GitHub {
readonly #githubToken: string;
readonly octokit: Octokit;
Expand Down Expand Up @@ -212,18 +226,57 @@ export class GitHub {
);
}

async pushTag(tag: string) {
async #getRemoteTagCommit(tag: string) {
const { data: ref } = await this.octokit.rest.git.getRef({
...context.repo,
ref: `tags/${tag}`,
});
let target: { type: string; sha: string } = ref.object;
while (target.type === "tag") {
const { data: tagObject } = await this.octokit.rest.git.getTag({
...context.repo,
tag_sha: target.sha,
});
target = tagObject.object;
}
assert.equal(
target.type,
"commit",
`Tag ${tag} points to a ${target.type}, not a commit`,
);
return target.sha;
}

async pushTag(tag: string, commit = context.sha) {
if (!this.pushWithGitCli) {
return this.octokit.rest.git
.createRef({
try {
const { data: tagObject } = await this.octokit.rest.git.createTag({
...context.repo,
tag,
message: tag,
object: commit,
type: "commit",
});
await this.octokit.rest.git.createRef({
...context.repo,
ref: `refs/tags/${tag}`,
sha: context.sha,
})
.catch((err) => {
// Assuming tag was manually pushed in custom publish script
core.warning(`Failed to create tag ${tag}: ${err.message}`);
sha: tagObject.sha,
});
} catch (err) {
if (isAlreadyExistingRefError(err)) {
const remoteCommit = await this.#getRemoteTagCommit(tag);
assert.equal(
remoteCommit,
commit,
`Tag ${tag} points to commit ${remoteCommit}, expected ${commit}`,
);
// The tag was likely pushed by a custom publish script.
core.info(`Tag ${tag} already exists`);
return;
}
throw err;
}
return;
}
await exec("git", ["push", "origin", tag], {
cwd: this.cwd,
Expand Down
58 changes: 55 additions & 3 deletions src/run.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import assert from "node:assert/strict";
import { Buffer } from "node:buffer";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
Expand Down Expand Up @@ -155,6 +157,45 @@ async function readChangesetsOutput(outputPath: string) {
return events;
}

type Release = {
pkg: Package;
tag: string;
commit?: string;
};

async function getTagCommits(cwd: string, tags: string[]) {
if (tags.length === 0) {
return [];
}
const refs = tags.map((tag) => `refs/tags/${tag}^{}`);
const { stdout } = await getExecOutput(
"git",
["cat-file", "--batch-check=%(objectname) %(objecttype)"],
{
cwd,
input: Buffer.from(`${refs.join("\n")}\n`),
},
);
const lines = stdout.trim().split(/\r?\n/);
assert.equal(
lines.length,
tags.length,
"Git returned an unexpected number of tag targets",
);
return lines.map((line, index) => {
if (line === `${refs[index]} missing`) {
return undefined;
}
const [commit, type] = line.split(" ");
assert.equal(
type,
"commit",
`Tag ${tags[index]} does not point to a commit`,
);
return commit;
});
}

export async function runPublish({
script,
fromPackDir,
Expand Down Expand Up @@ -214,7 +255,7 @@ export async function runPublish({
);
output = [];
}
let releases = output.map((event) => {
let releases: Release[] = output.map((event) => {
let pkg = packagesByName.get(event.packageName);
if (pkg === undefined) {
throw new Error(
Expand All @@ -232,11 +273,22 @@ export async function runPublish({
);
}

if (pushGitTags) {
const commits = await getTagCommits(
cwd,
releases.map(({ tag }) => tag),
);
releases = releases.map((release, index) => ({
...release,
commit: commits[index],
}));
}

if (createGithubReleases || pushGitTags) {
await Promise.all(
releases.map(async ({ pkg, tag }) => {
releases.map(async ({ pkg, tag, commit }) => {
if (pushGitTags) {
await github.pushTag(tag);
await github.pushTag(tag, commit);
}
if (createGithubReleases) {
await createRelease(octokit, { pkg, tagName: tag });
Expand Down
Loading