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
56 changes: 56 additions & 0 deletions tools/autoflow/__tests__/release-lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { assert, assertEquals } from '@std/assert';
import { acquireReleaseLock, RELEASE_LOCK_PATH, type ReleaseLock } from '../release-lock.ts';

function held(lock: ReleaseLock): { release: () => Promise<void> } {
assert(lock.acquired, lock.acquired ? '' : lock.reason);
return lock;
}

Deno.test('release lock: a second acquisition fails while the first is held', async () => {
const dir = await Deno.makeTempDir();
const lockPath = `${dir}/release.lock`;
try {
const first = held(await acquireReleaseLock(lockPath, 'release-prepare'));
const second = await acquireReleaseLock(lockPath, 'publish-existing');
assertEquals(second.acquired, false);
if (!second.acquired) {
assert(second.reason.includes(lockPath), second.reason);
assert(second.reason.includes('release-prepare'), second.reason);
}
await first.release();
const third = held(await acquireReleaseLock(lockPath, 'publish-existing'));
await third.release();
} finally {
await Deno.remove(dir, { recursive: true });
}
});

Deno.test('release lock: stale lock from a dead holder is reported, not silently broken', async () => {
const dir = await Deno.makeTempDir();
const lockPath = `${dir}/release.lock`;
try {
await Deno.writeTextFile(lockPath, JSON.stringify({ pid: 999999, command: 'release' }));
const attempt = await acquireReleaseLock(lockPath, 'release');
assertEquals(attempt.acquired, false);
} finally {
await Deno.remove(dir, { recursive: true });
}
});

Deno.test('release lock: double release is a no-op', async () => {
const dir = await Deno.makeTempDir();
const lockPath = `${dir}/release.lock`;
try {
const lock = held(await acquireReleaseLock(lockPath, 'release'));
await lock.release();
await lock.release();
const again = held(await acquireReleaseLock(lockPath, 'release'));
await again.release();
} finally {
await Deno.remove(dir, { recursive: true });
}
});

Deno.test('release lock: canonical path lives under the gitignored .artifacts dir', () => {
assertEquals(RELEASE_LOCK_PATH, '.artifacts/autoflow-release.lock');
});
117 changes: 77 additions & 40 deletions tools/autoflow/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
resolvePatchTargetVersion,
} from './release.ts';
import { PACKAGE_VERSION } from '../project-constants.ts';
import { normalizeReleaseVersion as normalizeLineVersion } from '../lib/version.ts';
import { acquireReleaseLock, RELEASE_LOCK_PATH, releaseLockSync } from './release-lock.ts';
import { runWithOutput } from '../lib/process.ts';

interface CliOptions {
Expand All @@ -41,7 +43,8 @@ interface GateResult {

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

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

/**
* Release-mutating commands (#1231 M14): the CI concurrency group in
* autoflow-release.yml serializes the hosted lane; this set is the local
* counterpart — each of these commands takes the deterministic repo-local
* lock (release-lock.ts) before doing anything, so two local release
* operations cannot interleave. Read-only tiers (dev/push/ci) never lock.
*/
const RELEASE_LOCK_COMMANDS = new Set([
'patch-release',
'release',
'release-prepare',
'publish-existing',
'release-record',
]);

export async function main(args: string[]): Promise<void> {
const options = parseArgs(args);

switch (options.command) {
case 'dev':
await runTier('dev', options.dryRun);
break;
case 'push':
await runTier('push', options.dryRun);
break;
case 'ci':
await runTier('ci', options.dryRun);
break;
case 'patch-release':
await runPatchRelease(options.dryRun, options.approvedPlan, options.prCiEvidence);
break;
case 'release':
await runApprovedRelease(
options.approvedPlan,
options.targetVersion,
options.dryRun,
options.prCiEvidence,
);
break;
case 'release-prepare':
await runReleasePrepare(
options.approvedPlan,
options.targetVersion,
options.dryRun,
options.prCiEvidence,
);
break;
case 'publish-existing':
await runPublishExisting(options.targetVersion, options.dryRun, options.prCiEvidence);
break;
case 'release-record':
await runReleaseRecord(options.targetVersion, options.dryRun);
break;
default:
console.error(
'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]',
);
let release: (() => Promise<void>) | undefined;
if (RELEASE_LOCK_COMMANDS.has(options.command)) {
const lock = await acquireReleaseLock(RELEASE_LOCK_PATH, options.command);
if (!lock.acquired) {
console.error(`Refusing to run ${options.command}: ${lock.reason}`);
Deno.exit(1);
}
release = lock.release;
// Gate failures inside the release plan Deno.exit(1) directly; the unload
// hook (Deno.exit dispatches unload) plus the finally below release the
// lock on every exit path short of a hard kill, which leaves a stale lock
// the next run reports by name.
globalThis.addEventListener('unload', () => releaseLockSync(RELEASE_LOCK_PATH));
}

try {
switch (options.command) {
case 'dev':
await runTier('dev', options.dryRun);
break;
case 'push':
await runTier('push', options.dryRun);
break;
case 'ci':
await runTier('ci', options.dryRun);
break;
case 'patch-release':
await runPatchRelease(options.dryRun, options.approvedPlan, options.prCiEvidence);
break;
case 'release':
await runApprovedRelease(
options.approvedPlan,
options.targetVersion,
options.dryRun,
options.prCiEvidence,
);
break;
case 'release-prepare':
await runReleasePrepare(
options.approvedPlan,
options.targetVersion,
options.dryRun,
options.prCiEvidence,
);
break;
case 'publish-existing':
await runPublishExisting(options.targetVersion, options.dryRun, options.prCiEvidence);
break;
case 'release-record':
await runReleaseRecord(options.targetVersion, options.dryRun);
break;
default:
console.error(
'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]',
);
Deno.exit(1);
}
} finally {
await release?.();
}
}

Expand Down
86 changes: 86 additions & 0 deletions tools/autoflow/release-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Local release mutual exclusion (#1231 M14).
*
* CI serializes release operations through the `openelement-release`
* concurrency group in .github/workflows/autoflow-release.yml, but nothing
* stopped two LOCAL release invocations (release-prepare, publish-existing,
* patch-release, release, release-record) from interleaving in one working
* copy — both rewriting project-constants, evidence records and package
* manifests mid-plan. This lock closes that gap deterministically: the lock
* file is created with `createNew`, so exactly one contender wins and every
* other exits with the holder's identity.
*
* The lock lives under the gitignored `.artifacts/` scratch dir so a held or
* stale lock never dirties the worktree. A holder killed before releasing
* leaves a stale lock: the next release fails closed and names the file to
* remove — recovery is a deliberate human act, never silent lock-breaking.
*/

export const RELEASE_LOCK_PATH = '.artifacts/autoflow-release.lock';

export type ReleaseLock =
| { acquired: true; release: () => Promise<void> }
| { acquired: false; reason: string };

/**
* Try to take the release lock at `lockPath` for `command`. Exactly one
* concurrent caller acquires; the rest get `acquired: false` with the current
* holder's recorded identity.
*/
export async function acquireReleaseLock(
lockPath: string,
command: string,
): Promise<ReleaseLock> {
// The parent (.artifacts/) is gitignored scratch and may not exist yet.
const parent = lockPath.slice(0, lockPath.lastIndexOf('/'));
if (parent) await Deno.mkdir(parent, { recursive: true });
let file: Deno.FsFile;
try {
file = await Deno.open(lockPath, { createNew: true, write: true });
} catch (err) {
if (err instanceof Deno.errors.AlreadyExists) {
let holder = '<holder unreadable>';
try {
holder = (await Deno.readTextFile(lockPath)).trim();
} catch {
// The holder may be mid-write; the lock still stands.
}
return {
acquired: false,
reason: `another release operation holds the lock at ${lockPath} (${holder}). ` +
'If no release is actually running, remove that stale lock file and retry.',
};
}
throw err;
}
const holder = JSON.stringify({
pid: Deno.pid,
command,
startedAt: new Date().toISOString(),
});
await file.write(new TextEncoder().encode(holder));
file.close();
let held = true;
return {
acquired: true,
release: async () => {
if (!held) return;
held = false;
await Deno.remove(lockPath).catch(() => {
// Already gone (e.g. operator removed it); nothing to release.
});
},
};
}

/**
* Synchronously drop the lock at `lockPath` if held. Registered by the CLI on
* `unload` so the `Deno.exit` paths inside the release plan still release.
*/
export function releaseLockSync(lockPath: string): void {
try {
Deno.removeSync(lockPath);
} catch {
// Never held or already released.
}
}
45 changes: 16 additions & 29 deletions tools/autoflow/release.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { AUTOFLOW3_POLICY_VERSION, isCI } from './policy.ts';
import { compare as compareSemver, parse as parseSemver, type SemVer } from '@std/semver';
import {
compareVersions as compareLineVersions,
FIRST_TAGGED_VERSION,
nextPatchVersion as nextLinePatchVersion,
} from '../lib/version.ts';
import {
PACKAGE_VERSION,
PREVIOUS_PACKAGE_VERSION,
Expand Down Expand Up @@ -74,33 +78,16 @@ export interface ReleaseCommandStep {
run?: (evidence: ReleaseEvidence) => Promise<void>;
}

/**
* Canonical prerelease/version truth lives in ../lib/version.ts (#1231 M16);
* these wrappers keep the established release.ts import surface unchanged.
*
* Pre-release line semantics: bump the pre-release counter, not the patch, so
* a version like 0.41.0-alpha.6 advances to 0.41.0-alpha.7 instead of the
* stable 0.41.1 (which would silently leave pre-release scope).
*/
export function nextPatchVersion(version: string): string {
let parsed: SemVer;
try {
parsed = parseSemver(version);
} catch {
throw new Error(`Invalid semver version: ${version}`);
}
// Strict x.y.z(-label.n) only: reject the v/= prefixes and build metadata
// that @std/semver otherwise tolerates.
if (!/^\d/u.test(version) || (parsed.build ?? []).length > 0) {
throw new Error(`Invalid semver version: ${version}`);
}
const { major, minor, patch } = parsed;
const prerelease = parsed.prerelease ?? [];

// Pre-release line: bump the pre-release counter, not the patch, so a
// version like 0.41.0-alpha.6 advances to 0.41.0-alpha.7 instead of
// the stable 0.41.1 (which would silently leave pre-release scope).
if (prerelease.length > 0) {
const [preName, preNum] = prerelease;
if (prerelease.length !== 2 || typeof preName !== 'string' || typeof preNum !== 'number') {
throw new Error(`Invalid semver version: ${version}`);
}
return `${major}.${minor}.${patch}-${preName}.${preNum + 1}`;
}

return `${major}.${minor}.${patch + 1}`;
return nextLinePatchVersion(version);
}

/**
Expand Down Expand Up @@ -530,7 +517,7 @@ export function createPreparePlan(
* can widen.
*/
export async function assertForwardOnlyTags(targetVersion: string): Promise<void> {
const firstTagged = '0.41.0-alpha.14';
const firstTagged = FIRST_TAGGED_VERSION;
const min = compareVersions(targetVersion, firstTagged);
if (min < 0) return; // Pre-window releases are legacy; no forward-only claim.
const untagged: string[] = [];
Expand All @@ -556,7 +543,7 @@ export async function assertForwardOnlyTags(targetVersion: string): Promise<void

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

/**
Expand Down
12 changes: 6 additions & 6 deletions tools/autoflow/version-anchors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
PREVIOUS_PACKAGE_VERSION,
PREVIOUS_PACKAGE_VERSION_TAG,
} from '../project-constants.ts';
import { prereleaseChannel, prereleaseParts } from '../lib/version.ts';

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

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

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