From a60abaee450adcafc472c1622df8a841c571fe16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Mon, 3 Aug 2026 18:06:15 +0200 Subject: [PATCH 1/7] Push annotated tags --- src/github.ts | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/github.ts b/src/github.ts index c03cbda8..a741aeda 100644 --- a/src/github.ts +++ b/src/github.ts @@ -67,6 +67,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; @@ -214,16 +227,28 @@ export class GitHub { async pushTag(tag: string) { 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: context.sha, + 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)) { + // 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, From 3fedbeed23ee66840d381ab8d946660a00f9c880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 4 Aug 2026 11:28:02 +0200 Subject: [PATCH 2/7] create tags on resolved commit --- src/github.ts | 4 ++-- src/run.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/github.ts b/src/github.ts index a741aeda..1b8e47dd 100644 --- a/src/github.ts +++ b/src/github.ts @@ -225,14 +225,14 @@ export class GitHub { ); } - async pushTag(tag: string) { + async pushTag(tag: string, commit = context.sha) { if (!this.pushWithGitCli) { try { const { data: tagObject } = await this.octokit.rest.git.createTag({ ...context.repo, tag, message: tag, - object: context.sha, + object: commit, type: "commit", }); await this.octokit.rest.git.createRef({ diff --git a/src/run.ts b/src/run.ts index 37dd2806..2bfdd2d8 100644 --- a/src/run.ts +++ b/src/run.ts @@ -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"; @@ -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}^{commit}`); + 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, @@ -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( @@ -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 }); From f8d9125b623f7bab4df725f0b60df1f6cfc5045b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 4 Aug 2026 11:31:45 +0200 Subject: [PATCH 3/7] stop pushing stable release tags in the custom release script --- scripts/release.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/scripts/release.ts b/scripts/release.ts index 7d30fa10..e5db9968 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -33,13 +33,7 @@ if (isPrerelease) { } else { await exec( "git", - [ - "push", - "--force", - "--follow-tags", - "origin", - `HEAD:refs/heads/${releaseLine}`, - ], + ["push", "--force", "origin", `HEAD:refs/heads/${releaseLine}`], { env: gitEnv, }, From 5e8919cc41d19fbe4c72753b285fbe5b1f878153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 4 Aug 2026 11:38:39 +0200 Subject: [PATCH 4/7] push prereleases branches --- scripts/release.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/scripts/release.ts b/scripts/release.ts index e5db9968..5e40c6f4 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,11 +1,15 @@ import { Buffer } from "node:buffer"; import path from "node:path"; import { exec } 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"); @@ -26,16 +30,10 @@ await exec("git", ["commit", "-m", tag]); await exec("changeset", ["git-tag"]); -if (isPrerelease) { - await exec("git", ["push", "origin", `refs/tags/${tag}`], { +await exec( + "git", + ["push", "--force", "origin", `HEAD:refs/heads/${releaseLine}`], + { env: gitEnv, - }); -} else { - await exec( - "git", - ["push", "--force", "origin", `HEAD:refs/heads/${releaseLine}`], - { - env: gitEnv, - }, - ); -} + }, +); From 70561459004965e71a0cd296133d22482026d3d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 4 Aug 2026 11:50:15 +0200 Subject: [PATCH 5/7] harden against tags pointing to non-commits --- scripts/release.ts | 8 +++++++- src/run.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/release.ts b/scripts/release.ts index 5e40c6f4..6de1ef26 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -32,7 +32,13 @@ await exec("changeset", ["git-tag"]); await exec( "git", - ["push", "--force", "origin", `HEAD:refs/heads/${releaseLine}`], + [ + "push", + "--force", + "--no-follow-tags", + "origin", + `HEAD:refs/heads/${releaseLine}`, + ], { env: gitEnv, }, diff --git a/src/run.ts b/src/run.ts index 2bfdd2d8..453421ed 100644 --- a/src/run.ts +++ b/src/run.ts @@ -167,7 +167,7 @@ async function getTagCommits(cwd: string, tags: string[]) { if (tags.length === 0) { return []; } - const refs = tags.map((tag) => `refs/tags/${tag}^{commit}`); + const refs = tags.map((tag) => `refs/tags/${tag}^{}`); const { stdout } = await getExecOutput( "git", ["cat-file", "--batch-check=%(objectname) %(objecttype)"], From 82b288dc3836b65dbb480529923ff8ca7ff69822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 4 Aug 2026 11:57:01 +0200 Subject: [PATCH 6/7] harden against tag<->commit mismatch --- src/github.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/github.ts b/src/github.ts index 1b8e47dd..f19593c9 100644 --- a/src/github.ts +++ b/src/github.ts @@ -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"; @@ -225,6 +226,27 @@ export class GitHub { ); } + 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) { try { @@ -242,6 +264,12 @@ export class GitHub { }); } 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; From f8187684e237e4fd84103868e3309b0fab7d7b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 4 Aug 2026 12:03:10 +0200 Subject: [PATCH 7/7] stable commits --- scripts/release.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/release.ts b/scripts/release.ts index 6de1ef26..503f688d 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,6 +1,6 @@ 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" }; @@ -25,8 +25,22 @@ 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"]); @@ -35,6 +49,7 @@ await exec( [ "push", "--force", + // The action pushes tags through the API; override any push.followTags config. "--no-follow-tags", "origin", `HEAD:refs/heads/${releaseLine}`,