From e087fa465c24d86883823ac9da7ee928c6ea9479 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 21 Aug 2026 20:06:38 +0100 Subject: [PATCH] fix(pr,pack,publish,release): improve coordination tag --- README.md | 2 +- pack/action.yml | 6 +++--- publish/action.yml | 6 +++--- release/action.yml | 2 +- scripts/_independent.ts | 34 ++++++++++++++++++++++++++-------- scripts/pack.ts | 4 ++-- scripts/tag-and-release.ts | 11 ++++++----- test/_independent.test.ts | 30 ++++++++++++++++++++++++------ 8 files changed, 66 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index a7265fb..8b36f36 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,7 @@ Bumping a package also releases anything that depends on it. (Only `dependencies Instead of one `release/vX.Y.Z` PR you'll get a single `release/-pending` PR covering every package with unreleased changes, with a section per package. -On merge, uppt tags each released package as `@X.Y.Z` (`fontaine@0.9.0`, `@nuxt/kit@5.0.0`) on the merge commit, then creates one GitHub release on a `release-YYYY-MM-DD-` coordination tag rather than picking one package's tag to stand for the whole set. The publish workflow is dispatched on that coordination tag with a `releases` payload: a JSON array of `{ "name", "version", "dir" }`. `uppt/pack` packs exactly those workspaces, and `uppt/publish` stages them all, so they go live together when you approve them on npmjs.com. +On merge, uppt tags each released package as `@X.Y.Z` (`fontaine@0.9.0`, `@nuxt/kit@5.0.0`) on the merge commit, then creates one GitHub release on a `release-YYYY-MM-DD` coordination tag (suffixed `.2`, `.3`, … for later releases the same day) rather than picking one package's tag to stand for the whole set. The release is titled with the packages it covers. The publish workflow is dispatched on that coordination tag with a `releases` payload: a JSON array of `{ "name", "version", "dir" }`. `uppt/pack` packs exactly those workspaces, and `uppt/publish` stages them all, so they go live together when you approve them on npmjs.com. Independent mode needs a few workflow changes on top of the lockstep setup: a `releases` dispatch input to carry the payload from the chained dispatch, `mode: independent` on `uppt/pr` and `uppt/release`, a looser head-ref guard (the PR branch is `release/-pending`), and job conditions that accept the coordination tag. Here is the whole thing: diff --git a/pack/action.yml b/pack/action.yml index 889f292..56672bf 100644 --- a/pack/action.yml +++ b/pack/action.yml @@ -24,7 +24,7 @@ inputs: required: false default: '' releases: - description: 'Independent-mode publish payload: the ordered JSON array of `{ name, version, dir }` entries emitted by `uppt/release` and delivered via the workflow''s `releases` dispatch input. Never set by hand. When present, exactly those workspaces are packed, in that order, and the run must be on the matching `release-YYYY-MM-DD-` coordination tag.' + description: 'Independent-mode publish payload: the ordered JSON array of `{ name, version, dir }` entries emitted by `uppt/release` and delivered via the workflow''s `releases` dispatch input. Never set by hand. When present, exactly those workspaces are packed, in that order, and the run must be on the matching `release-YYYY-MM-DD` coordination tag.' required: false default: '' @@ -51,8 +51,8 @@ runs: ;; esac if [ -n "${RELEASES:-}" ]; then - if ! printf '%s' "${GITHUB_REF:-}" | grep -Eq '^refs/tags/release-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]{7,40}$'; then - echo "::error::danielroe/uppt/pack got a 'releases' payload but the ref is not a 'refs/tags/release-YYYY-MM-DD-' coordination tag (got '${GITHUB_REF:-}')." + if ! printf '%s' "${GITHUB_REF:-}" | grep -Eq '^refs/tags/release-[0-9]{4}-[0-9]{2}-[0-9]{2}(\.[0-9]+)?$'; then + echo "::error::danielroe/uppt/pack got a 'releases' payload but the ref is not a 'refs/tags/release-YYYY-MM-DD' coordination tag (got '${GITHUB_REF:-}')." exit 1 fi elif [ "${GITHUB_REF:-}" = "${GITHUB_REF#refs/tags/v}" ]; then diff --git a/publish/action.yml b/publish/action.yml index 6a217d2..f1c2707 100644 --- a/publish/action.yml +++ b/publish/action.yml @@ -20,7 +20,7 @@ inputs: required: false default: '' releases: - description: 'Independent-mode publish payload: the ordered JSON array of `{ name, version, dir }` entries emitted by `uppt/release` and delivered via the workflow''s `releases` dispatch input. Never set by hand. When present, the run must be on the matching `release-YYYY-MM-DD-` coordination tag, and (when `files` is omitted) tarballs are staged in the payload''s topological order.' + description: 'Independent-mode publish payload: the ordered JSON array of `{ name, version, dir }` entries emitted by `uppt/release` and delivered via the workflow''s `releases` dispatch input. Never set by hand. When present, the run must be on the matching `release-YYYY-MM-DD` coordination tag, and (when `files` is omitted) tarballs are staged in the payload''s topological order.' required: false default: '' @@ -42,8 +42,8 @@ runs: ;; esac if [ -n "${RELEASES:-}" ]; then - if ! printf '%s' "${GITHUB_REF:-}" | grep -Eq '^refs/tags/release-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]{7,40}$'; then - echo "::error::danielroe/uppt/publish got a 'releases' payload but the ref is not a 'refs/tags/release-YYYY-MM-DD-' coordination tag (got '${GITHUB_REF:-}')." + if ! printf '%s' "${GITHUB_REF:-}" | grep -Eq '^refs/tags/release-[0-9]{4}-[0-9]{2}-[0-9]{2}(\.[0-9]+)?$'; then + echo "::error::danielroe/uppt/publish got a 'releases' payload but the ref is not a 'refs/tags/release-YYYY-MM-DD' coordination tag (got '${GITHUB_REF:-}')." exit 1 fi elif [ "${GITHUB_REF:-}" = "${GITHUB_REF#refs/tags/v}" ]; then diff --git a/release/action.yml b/release/action.yml index 68e7db9..f92bfa0 100644 --- a/release/action.yml +++ b/release/action.yml @@ -28,7 +28,7 @@ inputs: required: false default: '' mode: - description: 'Monorepo versioning mode: `lockstep` (default) or `independent`. Must match the value passed to `uppt/pr`. In independent mode the action tags each released package as `@X.Y.Z`, attaches the GitHub release to a `release-YYYY-MM-DD-` coordination tag, and dispatches the publish workflow with a `releases` payload input (which the caller''s workflow must declare under `workflow_dispatch`).' + description: 'Monorepo versioning mode: `lockstep` (default) or `independent`. Must match the value passed to `uppt/pr`. In independent mode the action tags each released package as `@X.Y.Z`, attaches the GitHub release to a `release-YYYY-MM-DD` coordination tag, and dispatches the publish workflow with a `releases` payload input (which the caller''s workflow must declare under `workflow_dispatch`).' required: false default: 'lockstep' allow-forks: diff --git a/scripts/_independent.ts b/scripts/_independent.ts index a98cdb8..0d18259 100644 --- a/scripts/_independent.ts +++ b/scripts/_independent.ts @@ -76,18 +76,36 @@ export function packageTag (entry: Pick): stri return `${entry.name}@${entry.version}` } -export const COORDINATION_TAG_RE = /^release-\d{4}-\d{2}-\d{2}-[0-9a-f]{7,40}$/ +export const COORDINATION_TAG_RE = /^release-\d{4}-\d{2}-\d{2}(?:\.\d+)?$/ /** - * Release-bearing tag for an independent merge: `release-YYYY-MM-DD-` - * (UTC date). The GitHub release attaches here so no single package's tag - * is privileged as "the" release. + * Release-bearing tag for an independent merge: `release-YYYY-MM-DD` (UTC + * date), suffixed `.2`, `.3`, … for the second and later releases on the + * same day. The GitHub release attaches here so no single package's tag is + * privileged as "the" release. + * + * @param date UTC date the release is cut on. + * @param isTaken Predicate deciding whether a candidate tag already exists. */ -export function coordinationTag (sha: string, date: Date = new Date()): string { - if (!/^[0-9a-f]{40}$/.test(sha)) { - throw new Error(`Expected a full commit sha, got "${sha}"`) +export function coordinationTag (date: Date = new Date(), isTaken: (tag: string) => boolean = () => false): string { + const base = `release-${date.toISOString().slice(0, 10)}` + if (!isTaken(base)) return base + for (let n = 2; n <= 100; n++) { + const candidate = `${base}.${n}` + if (!isTaken(candidate)) return candidate } - return `release-${date.toISOString().slice(0, 10)}-${sha.slice(0, 7)}` + throw new Error(`Could not find a free coordination tag: ${base} and ${base}.2…${base}.100 all exist.`) +} + +/** + * Human-readable GitHub release title for a release set: `@X.Y.Z` for + * a single package, otherwise the first few plus an `and N more` tail. + */ +export function releaseTitle (releases: ReleaseEntry[], date: Date = new Date()): string { + if (!releases.length) return date.toISOString().slice(0, 10) + const shown = releases.slice(0, 3).map(packageTag) + const rest = releases.length - shown.length + return rest > 0 ? `${shown.join(', ')} and ${rest} more` : shown.join(', ') } export function serialiseReleases (releases: ReleaseEntry[]): string { diff --git a/scripts/pack.ts b/scripts/pack.ts index 1d7974f..03c4b4c 100644 --- a/scripts/pack.ts +++ b/scripts/pack.ts @@ -22,7 +22,7 @@ // independent mode: an ordered array of // `{ name, version, dir }`. When set, exactly those // workspaces are packed, in that order, and the ref -// must be a `release-YYYY-MM-DD-` coordination +// must be a `release-YYYY-MM-DD` coordination // tag instead of `vX.Y.Z`. import process from 'node:process' @@ -69,7 +69,7 @@ function main () { if (releases) { const tag = ref.startsWith('refs/tags/') ? ref.slice('refs/tags/'.length) : '' if (!COORDINATION_TAG_RE.test(tag)) { - throw new Error(`RELEASES is set, so GITHUB_REF must be a 'refs/tags/release-YYYY-MM-DD-' coordination tag, got '${ref || ''}'`) + throw new Error(`RELEASES is set, so GITHUB_REF must be a 'refs/tags/release-YYYY-MM-DD' coordination tag, got '${ref || ''}'`) } } else { diff --git a/scripts/tag-and-release.ts b/scripts/tag-and-release.ts index 1065d09..eb4452c 100644 --- a/scripts/tag-and-release.ts +++ b/scripts/tag-and-release.ts @@ -16,14 +16,14 @@ // mode derives the release set from the tree (each // workspace's version vs its latest `@X.Y.Z` // tag), fans out one tag per released package, cuts a -// single release on a `release-YYYY-MM-DD-` +// single release on a `release-YYYY-MM-DD` // coordination tag, and dispatches the publish // workflow with a `releases` payload input. import process from 'node:process' import { execFileSync } from 'node:child_process' import { isSemver, resolveCurrentVersion, resolveWorkspaces } from './_workspaces.ts' -import { coordinationTag, deriveReleaseSet, packageTag, serialiseReleases } from './_independent.ts' +import { coordinationTag, deriveReleaseSet, packageTag, releaseTitle, serialiseReleases } from './_independent.ts' function run (cmd: string, args: string[], opts: { env?: NodeJS.ProcessEnv } = {}) { execFileSync(cmd, args, { stdio: 'inherit', env: { ...process.env, ...opts.env } }) @@ -77,10 +77,11 @@ function mainIndependent (repo: string, ghEnv: NodeJS.ProcessEnv) { } const sha = capture('git', ['rev-parse', 'HEAD']) - const coordTag = coordinationTag(sha) + const localTags = new Set(allTags()) + const coordTag = coordinationTag(new Date(), tag => localTags.has(tag) || tagExists(repo, tag, ghEnv)) const tags = [...releases.map(packageTag), coordTag] - const existing = tags.filter(tag => tagExists(repo, tag, ghEnv)) + const existing = tags.filter(tag => tag !== coordTag && tagExists(repo, tag, ghEnv)) if (existing.length) { throw new Error(`Refusing to tag: ${existing.join(', ')} already exist${existing.length === 1 ? 's' : ''} on ${repo}. If this is a rerun, delete the tags (and any release) from the previous attempt first.`) } @@ -101,7 +102,7 @@ function mainIndependent (repo: string, ghEnv: NodeJS.ProcessEnv) { } const body = process.env.PR_BODY ?? '' - run('gh', ['release', 'create', coordTag, '--title', coordTag, '--notes', body], { env: ghEnv }) + run('gh', ['release', 'create', coordTag, '--title', releaseTitle(releases), '--notes', body], { env: ghEnv }) const workflow = process.env.PUBLISH_WORKFLOW || 'release.yml' run('gh', ['workflow', 'run', workflow, '--ref', coordTag, '-f', `releases=${serialiseReleases(releases)}`], { env: ghEnv }) diff --git a/test/_independent.test.ts b/test/_independent.test.ts index c80bf00..c474f55 100644 --- a/test/_independent.test.ts +++ b/test/_independent.test.ts @@ -7,6 +7,7 @@ import { deriveReleaseSet, expectedTarballName, packageTag, + releaseTitle, releasesFromEnv, serialiseReleases, } from '../scripts/_independent.ts' @@ -91,15 +92,32 @@ describe('tag names', () => { expect(packageTag({ name: 'fontaine', version: '0.8.0' })).toBe('fontaine@0.8.0') }) - it('builds the coordination tag from the UTC date and short sha', () => { - const sha = 'abcdef0123456789abcdef0123456789abcdef01' - const tag = coordinationTag(sha, new Date('2026-02-03T23:59:00Z')) - expect(tag).toBe('release-2026-02-03-abcdef0') + it('builds the coordination tag from the UTC date', () => { + const tag = coordinationTag(new Date('2026-02-03T23:59:00Z')) + expect(tag).toBe('release-2026-02-03') expect(tag).toMatch(COORDINATION_TAG_RE) }) - it('refuses a non-sha input for the coordination tag', () => { - expect(() => coordinationTag('main')).toThrow(/full commit sha/) + it('increments the coordination tag for later releases the same day', () => { + const taken = new Set(['release-2026-02-03', 'release-2026-02-03.2']) + const tag = coordinationTag(new Date('2026-02-03T23:59:00Z'), t => taken.has(t)) + expect(tag).toBe('release-2026-02-03.3') + expect(tag).toMatch(COORDINATION_TAG_RE) + }) + + it('gives up rather than looping forever on a saturated day', () => { + expect(() => coordinationTag(new Date('2026-02-03T00:00:00Z'), () => true)).toThrow(/free coordination tag/) + }) + + it('titles the release with the packages it covers', () => { + expect(releaseTitle([{ name: 'fontaine', version: '0.9.0', dir: '.' }])).toBe('fontaine@0.9.0') + expect(releaseTitle([ + { name: 'a', version: '1.0.0', dir: 'a' }, + { name: 'b', version: '2.0.0', dir: 'b' }, + { name: 'c', version: '3.0.0', dir: 'c' }, + { name: 'd', version: '4.0.0', dir: 'd' }, + ])).toBe('a@1.0.0, b@2.0.0, c@3.0.0 and 1 more') + expect(releaseTitle([], new Date('2026-02-03T00:00:00Z'))).toBe('2026-02-03') }) }) -- 2.51.2