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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

- Default-location screenshot PNGs, snapshot JSON files, and `record export` artifacts are now rolled back when the subsequent artifact-manifest append fails, so a manifest-validation failure no longer leaves an orphaned, unmanifested file under the session's `artifacts/` directory. Explicit `--out` paths supplied by the caller are preserved on failure because they belong to the user, not the session manifest ([#95](https://github.com/coder/agent-tty/pull/95), fixes [#79](https://github.com/coder/agent-tty/issues/79)).
- `EventLog.open` now closes the underlying file handle when validation (size-limit check or existing-content parsing) fails, preventing a file-descriptor leak on rejected session host startup ([#51](https://github.com/coder/agent-tty/pull/51)).
- `npm run release:prep` and `npm run release:finalize` now work on aube-only checkouts where `package-lock.json` does not exist. `readPackageVersions` / `assertPackageVersionsMatch` skip the lockfile-coherence assertions when `package-lock.json` is absent, and `release-prep.mjs` stages only `package.json` in that case. The npm-lockfile path is still fully supported when a `package-lock.json` is present. Without this fix, the documented release flow was broken after the `aube` migration in [#91](https://github.com/coder/agent-tty/pull/91).

### Notes

Expand Down
10 changes: 6 additions & 4 deletions docs/RELEASE-PROCESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Versions containing a hyphen, such as `-beta.0` or `-rc.0`, are published by the

### Changelog mode

Use `--changelog ci` for the default maintainer path. The prep commit will contain only `package.json` and `package-lock.json`; the `Release Changelog` workflow will update `CHANGELOG.md` on the release branch when needed.
Use `--changelog ci` for the default maintainer path. The prep commit will contain only the version files — `package.json` plus `package-lock.json` when present (after PR #91 this repo uses `aube-lock.yaml` instead, so the prep commit on the default branch contains only `package.json`). The `Release Changelog` workflow will update `CHANGELOG.md` on the release branch when needed.

```bash
npm run release:prep -- --version <version> --changelog ci
Expand Down Expand Up @@ -174,12 +174,13 @@ and commits the resulting `CHANGELOG.md` update back to the release branch. When

### Manual prep fallback

If the scripted prep path is blocked, use the manual fallback only from a clean, up-to-date `main` checkout:
If the scripted prep path is blocked, use the manual fallback only from a clean, up-to-date `main` checkout. Stage `package-lock.json` only if your checkout still has one (post-PR #91 the repo is aube-only and the file is absent):

```bash
git switch -c release/<version>
npm version <version> --no-git-tag-version
git add package.json package-lock.json
git add package.json
[[ -f package-lock.json ]] && git add package-lock.json
git commit -m "chore(release): <version>"
git push -u origin release/<version>
gh pr create --base main --head release/<version> --title "chore(release): <version>"
Expand All @@ -189,7 +190,8 @@ For the local changelog variant, run Communique after `npm version ... --no-git-

```bash
communique generate "v<version>" --changelog --repo coder/agent-tty
git add package.json package-lock.json CHANGELOG.md
git add package.json CHANGELOG.md
[[ -f package-lock.json ]] && git add package-lock.json
git commit -m "chore(release): <version>"
```

Expand Down
77 changes: 46 additions & 31 deletions scripts/release-helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -242,35 +242,48 @@ export function readPackageVersions(root = process.cwd()) {
readJsonFile(join(resolvedRoot, 'package.json'), 'package.json'),
'package.json',
);
const packageLock = assertPackageLike(
readJsonFile(join(resolvedRoot, 'package-lock.json'), 'package-lock.json'),
'package-lock.json',
);

const packageVersion = assertString(
packageJson.version,
'package.json version',
);
const lockfileVersion = assertString(
packageLock.version,
'package-lock.json version',
);
const packages = assertPackageLike(
packageLock.packages,
'package-lock.json packages',
);
const rootPackage = assertPackageLike(
packages[''],
'package-lock.json packages[""]',
);
const lockRootVersion = assertString(
rootPackage.version,
'package-lock.json packages[""].version',
);

// `package-lock.json` is optional: after the aube migration (PR #91) this
// repo no longer carries one. When present, its version fields are asserted
// for coherence with `package.json`; when absent, only `package.json` is
// validated and dependency pinning is delegated to `aube-lock.yaml`.
const packageLockPath = join(resolvedRoot, 'package-lock.json');
const hasPackageLock = existsSync(packageLockPath);

let lockfileVersion = null;
let lockRootVersion = null;

if (hasPackageLock) {
const packageLock = assertPackageLike(
readJsonFile(packageLockPath, 'package-lock.json'),
'package-lock.json',
);
lockfileVersion = assertString(
packageLock.version,
'package-lock.json version',
);
const packages = assertPackageLike(
packageLock.packages,
'package-lock.json packages',
);
const rootPackage = assertPackageLike(
packages[''],
'package-lock.json packages[""]',
);
lockRootVersion = assertString(
rootPackage.version,
'package-lock.json packages[""].version',
);
}

return {
packageName: assertString(packageJson.name, 'package.json name'),
packageVersion,
hasPackageLock,
lockfileVersion,
lockRootVersion,
};
Expand All @@ -290,16 +303,18 @@ export function assertPackageVersionsMatch(
'agent-tty',
'package.json name must be agent-tty',
);
assert.equal(
versions.lockfileVersion,
versions.packageVersion,
'package-lock.json version must match package.json version',
);
assert.equal(
versions.lockRootVersion,
versions.packageVersion,
'package-lock.json packages[""].version must match package.json version',
);
if (versions.hasPackageLock) {
assert.equal(
versions.lockfileVersion,
versions.packageVersion,
'package-lock.json version must match package.json version',
);
assert.equal(
versions.lockRootVersion,
versions.packageVersion,
'package-lock.json packages[""].version must match package.json version',
);
}
if (expectedVersion !== null) {
assert.equal(
versions.packageVersion,
Expand Down
26 changes: 18 additions & 8 deletions scripts/release-prep.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';

Expand All @@ -25,14 +26,23 @@ import {
stageFiles,
} from './release-helpers.mjs';

const VERSION_FILE_PATHS = Object.freeze(['package.json', 'package-lock.json']);
// `package-lock.json` is included in the version-file allowlist only when it
// actually exists. After the aube migration (PR #91) the repo no longer ships
// one, but the npm-lockfile path is still supported for downstream consumers
// that re-introduce `package-lock.json`.
function resolveVersionFilePaths(root) {
return existsSync(join(root, 'package-lock.json'))
? Object.freeze(['package.json', 'package-lock.json'])
: Object.freeze(['package.json']);
}

// The env override is intentionally scoped to external release tools
// (release-it, Communique, and verification). Git operations use process.env
// because the supported entrypoint is spawning this script with the desired env.
export function releasePrep(argv = process.argv.slice(2), env = process.env) {
const options = parsePrepArgs(argv);
const root = assertRepoRoot(process.cwd());
const versionFilePaths = resolveVersionFilePaths(root);
const { packageVersion } = assertPackageVersionsMatch(root);
assertTargetVersionIsGreater(packageVersion, options.version);

Expand All @@ -53,24 +63,24 @@ export function releasePrep(argv = process.argv.slice(2), env = process.env) {
if (options.changelog === 'local') {
runCommunique(root, options.version, env);
const changedFiles = assertAllowedChangedFiles(root, [
...VERSION_FILE_PATHS,
...versionFilePaths,
'CHANGELOG.md',
]);
assertExpectedFilesChanged(changedFiles, [
...VERSION_FILE_PATHS,
...versionFilePaths,
'CHANGELOG.md',
]);
stageFiles(root, [...VERSION_FILE_PATHS, 'CHANGELOG.md']);
stageFiles(root, [...versionFilePaths, 'CHANGELOG.md']);
} else {
const changedFiles = assertAllowedChangedFiles(root, [
...VERSION_FILE_PATHS,
...versionFilePaths,
'CHANGELOG.md',
]);
if (changedFiles.includes('CHANGELOG.md')) {
throw new Error('CHANGELOG.md must not change when using --changelog ci');
}
assertExpectedFilesChanged(changedFiles, VERSION_FILE_PATHS);
stageFiles(root, VERSION_FILE_PATHS);
assertExpectedFilesChanged(changedFiles, versionFilePaths);
stageFiles(root, versionFilePaths);
}

createCommit(root, `chore(release): ${options.version}`);
Expand Down
101 changes: 80 additions & 21 deletions test/integration/release-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,36 +68,47 @@ function runGit(repo: string, args: string[]): string {
return run('git', args, { cwd: repo, expectedStatus: 0 }).stdout.trim();
}

function writePackageFiles(repo: string, version: string): void {
function writePackageFiles(
repo: string,
version: string,
{ includePackageLock = true }: { includePackageLock?: boolean } = {},
): void {
const packageJson = {
name: 'agent-tty',
version,
type: 'module',
private: true,
};
const packageLock = {
name: 'agent-tty',
version,
lockfileVersion: 3,
requires: true,
packages: {
'': {
name: 'agent-tty',
version,
license: 'Apache-2.0',
},
},
};

writeFileSync(join(repo, 'package.json'), `${JSON.stringify(packageJson)}\n`);
writeFileSync(
join(repo, 'package-lock.json'),
`${JSON.stringify(packageLock)}\n`,
);

if (includePackageLock) {
const packageLock = {
name: 'agent-tty',
version,
lockfileVersion: 3,
requires: true,
packages: {
'': {
name: 'agent-tty',
version,
license: 'Apache-2.0',
},
},
};
writeFileSync(
join(repo, 'package-lock.json'),
`${JSON.stringify(packageLock)}\n`,
);
}

writeFileSync(join(repo, 'CHANGELOG.md'), '# Changelog\n');
}

function createTempRepo(version = '0.1.1-beta.4'): TempRepo {
function createTempRepo(
version = '0.1.1-beta.4',
{ includePackageLock = true }: { includePackageLock?: boolean } = {},
): TempRepo {
const root = mkdtempSync(join(tmpdir(), 'agent-tty-release-scripts-'));
tempRoots.push(root);
const origin = join(root, 'origin.git');
Expand All @@ -108,8 +119,11 @@ function createTempRepo(version = '0.1.1-beta.4'): TempRepo {
runGit(repo, ['remote', 'add', 'origin', origin]);
runGit(repo, ['config', 'user.name', 'Agent TTY Test']);
runGit(repo, ['config', 'user.email', 'agent-tty-test@example.invalid']);
writePackageFiles(repo, version);
runGit(repo, ['add', 'package.json', 'package-lock.json', 'CHANGELOG.md']);
writePackageFiles(repo, version, { includePackageLock });
const initialFiles = includePackageLock
? ['package.json', 'package-lock.json', 'CHANGELOG.md']
: ['package.json', 'CHANGELOG.md'];
runGit(repo, ['add', ...initialFiles]);
runGit(repo, ['commit', '-q', '-m', 'init']);
runGit(repo, ['push', '-q', '-u', 'origin', 'main']);

Expand Down Expand Up @@ -252,6 +266,39 @@ describe('release scripts', () => {
]);
});

it('prepares a release on an aube-only checkout (no package-lock.json)', () => {
const { repo } = createTempRepo(undefined, { includePackageLock: false });

const result = runReleasePrep(repo, [
'--version',
'0.1.1-beta.5',
'--changelog',
'ci',
]);

expect(result.status).toBe(0);
expect(result.stdout).toContain(
'Release prep commit created on release/0.1.1-beta.5.',
);
expect(runGit(repo, ['branch', '--show-current'])).toBe(
'release/0.1.1-beta.5',
);
expect(runGit(repo, ['status', '--short'])).toBe('');
expect(runGit(repo, ['rev-list', '--count', 'origin/main..HEAD'])).toBe(
'1',
);
expect(runGit(repo, ['show', '-s', '--format=%s', 'HEAD'])).toBe(
'chore(release): 0.1.1-beta.5',
);
expect(
runGit(repo, ['diff', '--name-only', 'HEAD^..HEAD']).split('\n'),
).toEqual(['package.json']);
const packageJson = JSON.parse(
readFileSync(join(repo, 'package.json'), 'utf8'),
) as { version: string };
expect(packageJson.version).toBe('0.1.1-beta.5');
});

it('prepares a release from a repo root reached through a symlink', () => {
const { root, repo } = createTempRepo();
const linkedRepo = join(root, 'repo-link');
Expand Down Expand Up @@ -622,6 +669,18 @@ if (changedFiles.includes('CHANGELOG.md')) {
).toContain('refs/tags/v0.1.1-beta.5');
});

it('finalizes on an aube-only checkout (no package-lock.json)', () => {
const { repo } = createTempRepo('0.1.1-beta.5', {
includePackageLock: false,
});

const result = runReleaseFinalize(repo);

expect(result.status).toBe(0);
expect(result.stdout).toContain('Release tag v0.1.1-beta.5 pushed.');
expect(runGit(repo, ['tag', '--list'])).toBe('v0.1.1-beta.5');
});

it('refuses to finalize from a dirty tree', () => {
const { repo } = createTempRepo('0.1.1-beta.5');
writeFileSync(join(repo, 'CHANGELOG.md'), '# Changelog\n\nDirty work\n');
Expand Down
Loading