Skip to content

Commit 851c7f3

Browse files
author
DevBot
committed
fix(release): consolidate release locks, prerelease logic and version truth (#1231)
M14: add a deterministic local release lock (tools/autoflow/release-lock.ts) taken by every release-mutating autoflow command (patch-release, release, release-prepare, publish-existing, release-record). Acquisition is atomic (createNew) under the gitignored .artifacts/ scratch dir; a second local release operation fails closed and names the holder, and the lock is released on both the Deno.exit and exception paths. CI mutual exclusion keeps using the openelement-release concurrency group in autoflow-release.yml. M16: one canonical prerelease/version truth implementation in tools/lib/version.ts (import-free, so project-constants.ts stays loadable by Nitro/jiti under Node). bump-version, check-strategic-docs, check-version-anchors, check-docs-truth, check-release-truth, npm-release-verifier, release-evidence-consistency, autoflow cli/release/ version-anchors and publish-npm now import parseLineVersion / prereleaseParts / prereleaseSequence / prereleaseChannel / compareVersions / nextPatchVersion / normalizeReleaseVersion from it instead of re-rolling regexes; FIRST_TAGGED_VERSION ('0.41.0-alpha.14') now has a single copy (previously hard-coded in both autoflow/release.ts and check-docs-truth.ts). M17: the docs-truth current gate whitelist (CURRENT_DOC_ALLOWED) switches from substring matching to exact-path semantics: trailing-'/' entries are repo-anchored directory prefixes, all other entries are exact file paths. Incidental substring exemptions (e.g. docs/runbooks/supabase-migrations.md via 'migration') are now gated and pass the scan; dead entries dropped. freeze:semantics local/CI diff-semantics gap (Beta.1 carried risk): the module header now carries the precise characterization — divergence between the origin/main local base and the origin/$GITHUB_BASE_REF CI base is provably one-directional (fail closed) under the ADR-0151 train topology (origin/main is an ancestor of every dev-based PR HEAD, so the local diff is a superset of the CI diff while local amendment signals are a subset), and documents why defaulting the local base to origin/dev would be fail-open for dev→main release PRs.
1 parent 2e269ea commit 851c7f3

18 files changed

Lines changed: 594 additions & 193 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { assert, assertEquals } from '@std/assert';
2+
import { acquireReleaseLock, RELEASE_LOCK_PATH, type ReleaseLock } from '../release-lock.ts';
3+
4+
function held(lock: ReleaseLock): { release: () => Promise<void> } {
5+
assert(lock.acquired, lock.acquired ? '' : lock.reason);
6+
return lock;
7+
}
8+
9+
Deno.test('release lock: a second acquisition fails while the first is held', async () => {
10+
const dir = await Deno.makeTempDir();
11+
const lockPath = `${dir}/release.lock`;
12+
try {
13+
const first = held(await acquireReleaseLock(lockPath, 'release-prepare'));
14+
const second = await acquireReleaseLock(lockPath, 'publish-existing');
15+
assertEquals(second.acquired, false);
16+
if (!second.acquired) {
17+
assert(second.reason.includes(lockPath), second.reason);
18+
assert(second.reason.includes('release-prepare'), second.reason);
19+
}
20+
await first.release();
21+
const third = held(await acquireReleaseLock(lockPath, 'publish-existing'));
22+
await third.release();
23+
} finally {
24+
await Deno.remove(dir, { recursive: true });
25+
}
26+
});
27+
28+
Deno.test('release lock: stale lock from a dead holder is reported, not silently broken', async () => {
29+
const dir = await Deno.makeTempDir();
30+
const lockPath = `${dir}/release.lock`;
31+
try {
32+
await Deno.writeTextFile(lockPath, JSON.stringify({ pid: 999999, command: 'release' }));
33+
const attempt = await acquireReleaseLock(lockPath, 'release');
34+
assertEquals(attempt.acquired, false);
35+
} finally {
36+
await Deno.remove(dir, { recursive: true });
37+
}
38+
});
39+
40+
Deno.test('release lock: double release is a no-op', async () => {
41+
const dir = await Deno.makeTempDir();
42+
const lockPath = `${dir}/release.lock`;
43+
try {
44+
const lock = held(await acquireReleaseLock(lockPath, 'release'));
45+
await lock.release();
46+
await lock.release();
47+
const again = held(await acquireReleaseLock(lockPath, 'release'));
48+
await again.release();
49+
} finally {
50+
await Deno.remove(dir, { recursive: true });
51+
}
52+
});
53+
54+
Deno.test('release lock: canonical path lives under the gitignored .artifacts dir', () => {
55+
assertEquals(RELEASE_LOCK_PATH, '.artifacts/autoflow-release.lock');
56+
});

tools/autoflow/cli.ts

Lines changed: 77 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
resolvePatchTargetVersion,
2424
} from './release.ts';
2525
import { PACKAGE_VERSION } from '../project-constants.ts';
26+
import { normalizeReleaseVersion as normalizeLineVersion } from '../lib/version.ts';
27+
import { acquireReleaseLock, RELEASE_LOCK_PATH, releaseLockSync } from './release-lock.ts';
2628
import { runWithOutput } from '../lib/process.ts';
2729

2830
interface CliOptions {
@@ -41,7 +43,8 @@ interface GateResult {
4143

4244
export function normalizeReleaseVersion(version: string | undefined): string | undefined {
4345
if (!version) return undefined;
44-
return version.replace(/-(alpha|beta|rc)(\d+)$/u, '-$1.$2');
46+
// Canonical prerelease/version truth: tools/lib/version.ts (#1231 M16).
47+
return normalizeLineVersion(version);
4548
}
4649

4750
export function parseArgs(args: string[]): CliOptions {
@@ -331,49 +334,83 @@ async function runApprovedRelease(
331334
await executeReleasePlan('approved-release', targetVersion, approvedPlan, dryRun);
332335
}
333336

337+
/**
338+
* Release-mutating commands (#1231 M14): the CI concurrency group in
339+
* autoflow-release.yml serializes the hosted lane; this set is the local
340+
* counterpart — each of these commands takes the deterministic repo-local
341+
* lock (release-lock.ts) before doing anything, so two local release
342+
* operations cannot interleave. Read-only tiers (dev/push/ci) never lock.
343+
*/
344+
const RELEASE_LOCK_COMMANDS = new Set([
345+
'patch-release',
346+
'release',
347+
'release-prepare',
348+
'publish-existing',
349+
'release-record',
350+
]);
351+
334352
export async function main(args: string[]): Promise<void> {
335353
const options = parseArgs(args);
336354

337-
switch (options.command) {
338-
case 'dev':
339-
await runTier('dev', options.dryRun);
340-
break;
341-
case 'push':
342-
await runTier('push', options.dryRun);
343-
break;
344-
case 'ci':
345-
await runTier('ci', options.dryRun);
346-
break;
347-
case 'patch-release':
348-
await runPatchRelease(options.dryRun, options.approvedPlan, options.prCiEvidence);
349-
break;
350-
case 'release':
351-
await runApprovedRelease(
352-
options.approvedPlan,
353-
options.targetVersion,
354-
options.dryRun,
355-
options.prCiEvidence,
356-
);
357-
break;
358-
case 'release-prepare':
359-
await runReleasePrepare(
360-
options.approvedPlan,
361-
options.targetVersion,
362-
options.dryRun,
363-
options.prCiEvidence,
364-
);
365-
break;
366-
case 'publish-existing':
367-
await runPublishExisting(options.targetVersion, options.dryRun, options.prCiEvidence);
368-
break;
369-
case 'release-record':
370-
await runReleaseRecord(options.targetVersion, options.dryRun);
371-
break;
372-
default:
373-
console.error(
374-
'Usage: deno run tools/autoflow/cli.ts <dev|push|ci|patch-release|release|release-prepare|publish-existing|release-record> [--dry-run] [--approved-plan ID] [--to VERSION] [--pr-ci PATH]',
375-
);
355+
let release: (() => Promise<void>) | undefined;
356+
if (RELEASE_LOCK_COMMANDS.has(options.command)) {
357+
const lock = await acquireReleaseLock(RELEASE_LOCK_PATH, options.command);
358+
if (!lock.acquired) {
359+
console.error(`Refusing to run ${options.command}: ${lock.reason}`);
376360
Deno.exit(1);
361+
}
362+
release = lock.release;
363+
// Gate failures inside the release plan Deno.exit(1) directly; the unload
364+
// hook (Deno.exit dispatches unload) plus the finally below release the
365+
// lock on every exit path short of a hard kill, which leaves a stale lock
366+
// the next run reports by name.
367+
globalThis.addEventListener('unload', () => releaseLockSync(RELEASE_LOCK_PATH));
368+
}
369+
370+
try {
371+
switch (options.command) {
372+
case 'dev':
373+
await runTier('dev', options.dryRun);
374+
break;
375+
case 'push':
376+
await runTier('push', options.dryRun);
377+
break;
378+
case 'ci':
379+
await runTier('ci', options.dryRun);
380+
break;
381+
case 'patch-release':
382+
await runPatchRelease(options.dryRun, options.approvedPlan, options.prCiEvidence);
383+
break;
384+
case 'release':
385+
await runApprovedRelease(
386+
options.approvedPlan,
387+
options.targetVersion,
388+
options.dryRun,
389+
options.prCiEvidence,
390+
);
391+
break;
392+
case 'release-prepare':
393+
await runReleasePrepare(
394+
options.approvedPlan,
395+
options.targetVersion,
396+
options.dryRun,
397+
options.prCiEvidence,
398+
);
399+
break;
400+
case 'publish-existing':
401+
await runPublishExisting(options.targetVersion, options.dryRun, options.prCiEvidence);
402+
break;
403+
case 'release-record':
404+
await runReleaseRecord(options.targetVersion, options.dryRun);
405+
break;
406+
default:
407+
console.error(
408+
'Usage: deno run tools/autoflow/cli.ts <dev|push|ci|patch-release|release|release-prepare|publish-existing|release-record> [--dry-run] [--approved-plan ID] [--to VERSION] [--pr-ci PATH]',
409+
);
410+
Deno.exit(1);
411+
}
412+
} finally {
413+
await release?.();
377414
}
378415
}
379416

tools/autoflow/release-lock.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Local release mutual exclusion (#1231 M14).
3+
*
4+
* CI serializes release operations through the `openelement-release`
5+
* concurrency group in .github/workflows/autoflow-release.yml, but nothing
6+
* stopped two LOCAL release invocations (release-prepare, publish-existing,
7+
* patch-release, release, release-record) from interleaving in one working
8+
* copy — both rewriting project-constants, evidence records and package
9+
* manifests mid-plan. This lock closes that gap deterministically: the lock
10+
* file is created with `createNew`, so exactly one contender wins and every
11+
* other exits with the holder's identity.
12+
*
13+
* The lock lives under the gitignored `.artifacts/` scratch dir so a held or
14+
* stale lock never dirties the worktree. A holder killed before releasing
15+
* leaves a stale lock: the next release fails closed and names the file to
16+
* remove — recovery is a deliberate human act, never silent lock-breaking.
17+
*/
18+
19+
export const RELEASE_LOCK_PATH = '.artifacts/autoflow-release.lock';
20+
21+
export type ReleaseLock =
22+
| { acquired: true; release: () => Promise<void> }
23+
| { acquired: false; reason: string };
24+
25+
/**
26+
* Try to take the release lock at `lockPath` for `command`. Exactly one
27+
* concurrent caller acquires; the rest get `acquired: false` with the current
28+
* holder's recorded identity.
29+
*/
30+
export async function acquireReleaseLock(
31+
lockPath: string,
32+
command: string,
33+
): Promise<ReleaseLock> {
34+
// The parent (.artifacts/) is gitignored scratch and may not exist yet.
35+
const parent = lockPath.slice(0, lockPath.lastIndexOf('/'));
36+
if (parent) await Deno.mkdir(parent, { recursive: true });
37+
let file: Deno.FsFile;
38+
try {
39+
file = await Deno.open(lockPath, { createNew: true, write: true });
40+
} catch (err) {
41+
if (err instanceof Deno.errors.AlreadyExists) {
42+
let holder = '<holder unreadable>';
43+
try {
44+
holder = (await Deno.readTextFile(lockPath)).trim();
45+
} catch {
46+
// The holder may be mid-write; the lock still stands.
47+
}
48+
return {
49+
acquired: false,
50+
reason: `another release operation holds the lock at ${lockPath} (${holder}). ` +
51+
'If no release is actually running, remove that stale lock file and retry.',
52+
};
53+
}
54+
throw err;
55+
}
56+
const holder = JSON.stringify({
57+
pid: Deno.pid,
58+
command,
59+
startedAt: new Date().toISOString(),
60+
});
61+
await file.write(new TextEncoder().encode(holder));
62+
file.close();
63+
let held = true;
64+
return {
65+
acquired: true,
66+
release: async () => {
67+
if (!held) return;
68+
held = false;
69+
await Deno.remove(lockPath).catch(() => {
70+
// Already gone (e.g. operator removed it); nothing to release.
71+
});
72+
},
73+
};
74+
}
75+
76+
/**
77+
* Synchronously drop the lock at `lockPath` if held. Registered by the CLI on
78+
* `unload` so the `Deno.exit` paths inside the release plan still release.
79+
*/
80+
export function releaseLockSync(lockPath: string): void {
81+
try {
82+
Deno.removeSync(lockPath);
83+
} catch {
84+
// Never held or already released.
85+
}
86+
}

tools/autoflow/release.ts

Lines changed: 16 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { AUTOFLOW3_POLICY_VERSION, isCI } from './policy.ts';
2-
import { compare as compareSemver, parse as parseSemver, type SemVer } from '@std/semver';
2+
import {
3+
compareVersions as compareLineVersions,
4+
FIRST_TAGGED_VERSION,
5+
nextPatchVersion as nextLinePatchVersion,
6+
} from '../lib/version.ts';
37
import {
48
PACKAGE_VERSION,
59
PREVIOUS_PACKAGE_VERSION,
@@ -74,33 +78,16 @@ export interface ReleaseCommandStep {
7478
run?: (evidence: ReleaseEvidence) => Promise<void>;
7579
}
7680

81+
/**
82+
* Canonical prerelease/version truth lives in ../lib/version.ts (#1231 M16);
83+
* these wrappers keep the established release.ts import surface unchanged.
84+
*
85+
* Pre-release line semantics: bump the pre-release counter, not the patch, so
86+
* a version like 0.41.0-alpha.6 advances to 0.41.0-alpha.7 instead of the
87+
* stable 0.41.1 (which would silently leave pre-release scope).
88+
*/
7789
export function nextPatchVersion(version: string): string {
78-
let parsed: SemVer;
79-
try {
80-
parsed = parseSemver(version);
81-
} catch {
82-
throw new Error(`Invalid semver version: ${version}`);
83-
}
84-
// Strict x.y.z(-label.n) only: reject the v/= prefixes and build metadata
85-
// that @std/semver otherwise tolerates.
86-
if (!/^\d/u.test(version) || (parsed.build ?? []).length > 0) {
87-
throw new Error(`Invalid semver version: ${version}`);
88-
}
89-
const { major, minor, patch } = parsed;
90-
const prerelease = parsed.prerelease ?? [];
91-
92-
// Pre-release line: bump the pre-release counter, not the patch, so a
93-
// version like 0.41.0-alpha.6 advances to 0.41.0-alpha.7 instead of
94-
// the stable 0.41.1 (which would silently leave pre-release scope).
95-
if (prerelease.length > 0) {
96-
const [preName, preNum] = prerelease;
97-
if (prerelease.length !== 2 || typeof preName !== 'string' || typeof preNum !== 'number') {
98-
throw new Error(`Invalid semver version: ${version}`);
99-
}
100-
return `${major}.${minor}.${patch}-${preName}.${preNum + 1}`;
101-
}
102-
103-
return `${major}.${minor}.${patch + 1}`;
90+
return nextLinePatchVersion(version);
10491
}
10592

10693
/**
@@ -530,7 +517,7 @@ export function createPreparePlan(
530517
* can widen.
531518
*/
532519
export async function assertForwardOnlyTags(targetVersion: string): Promise<void> {
533-
const firstTagged = '0.41.0-alpha.14';
520+
const firstTagged = FIRST_TAGGED_VERSION;
534521
const min = compareVersions(targetVersion, firstTagged);
535522
if (min < 0) return; // Pre-window releases are legacy; no forward-only claim.
536523
const untagged: string[] = [];
@@ -556,7 +543,7 @@ export async function assertForwardOnlyTags(targetVersion: string): Promise<void
556543

557544
/** Numeric semver compare for x.y.z(-prerelease); prerelease < release. */
558545
export function compareVersions(a: string, b: string): number {
559-
return compareSemver(parseSemver(a), parseSemver(b));
546+
return compareLineVersions(a, b);
560547
}
561548

562549
/**

tools/autoflow/version-anchors.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
PREVIOUS_PACKAGE_VERSION,
1616
PREVIOUS_PACKAGE_VERSION_TAG,
1717
} from '../project-constants.ts';
18+
import { prereleaseChannel, prereleaseParts } from '../lib/version.ts';
1819

1920
export function releaseTag(version: string): string {
2021
return `v${version}`;
@@ -38,7 +39,7 @@ export interface PublishedReleaseState {
3839
*/
3940
export function advancePublishedReleaseStateText(text: string, version: string): string {
4041
const state = JSON.parse(text) as PublishedReleaseState;
41-
const prerelease = version.match(/-(alpha|beta)(?:\.|$)/u)?.[1];
42+
const prerelease = prereleaseParts(version)?.name;
4243
return `${
4344
JSON.stringify(
4445
{
@@ -63,10 +64,9 @@ export async function updatePublishedReleaseState(version: string): Promise<void
6364
}
6465

6566
export function nextPrereleaseTag(version: string): string {
66-
const match = version.match(/^(\d+\.\d+\.\d+)-([a-zA-Z]+)\.(\d+)$/u);
67-
if (!match) return releaseTag(version);
68-
const [, base, name, number] = match;
69-
return `v${base}-${name}.${Number(number) + 1}`;
67+
const parts = prereleaseParts(version);
68+
if (!parts) return releaseTag(version);
69+
return `v${parts.base}-${parts.name}.${parts.num + 1}`;
7070
}
7171

7272
/**
@@ -428,7 +428,7 @@ export function buildVersionAnchorReplacements(
428428
// would otherwise consume the anchor first, stranding the stale suffix.
429429
// Docs whose registry line carries no annotation (VERSION_PLAN,
430430
// PROJECT_WORKFLOW) need no rule — the generic replacement is correct.
431-
const prereleaseDistTag = version.match(/-(alpha|beta|rc)\.\d+$/u)?.[1];
431+
const prereleaseDistTag = prereleaseChannel(version);
432432
const prereleaseRules: Array<[string, string, string]> = [];
433433
if (prereleaseDistTag !== undefined) {
434434
for (const path of ['README.md', 'docs/roadmap/ROADMAP.md', 'docs/status/STATUS.md']) {

0 commit comments

Comments
 (0)