diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b70f259..8023b296 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - release/0.3 pull_request: branches: - main diff --git a/.github/workflows/release-truth.yml b/.github/workflows/release-truth.yml index a55cf4d7..b9afd7f4 100644 --- a/.github/workflows/release-truth.yml +++ b/.github/workflows/release-truth.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - release/0.3 schedule: - cron: '17 11 * * *' workflow_dispatch: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f9e2d9e..16b5aa74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,11 +31,22 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Verify release commit belongs to main + - name: Verify release commit belongs to a trusted release line shell: bash run: | - git fetch --no-tags origin main - git merge-base --is-ancestor HEAD origin/main + git fetch --no-tags origin main release/0.3 + + if git merge-base --is-ancestor HEAD origin/main; then + exit 0 + fi + + if [[ "$RELEASE_TAG" =~ ^v0\.3\.[0-9]+$ ]] && \ + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/release/0.3)"; then + exit 0 + fi + + echo "Release commit is not on main or the exact v0.3 maintenance head" >&2 + exit 1 - name: Set up Node.js and npm registry uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/README.md b/README.md index 6ae0e391..39bd496f 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ -> **Requires 0.3.0 or newer.** Check the installed CLI with `aether --version`. +> **Requires 0.3.2 or newer.** Check the installed CLI with `aether --version`. ## Quickstart @@ -171,7 +171,7 @@ badge or `npm view aether-agents version` for the npm `latest` dist-tag, and |---|---:|---| | npm `latest` | [![npm latest](https://img.shields.io/npm/v/aether-agents?label=&color=14b8a6)](https://www.npmjs.com/package/aether-agents) | Published package; the badge resolves the live dist-tag. | | PyPI `aether-agent` | [![PyPI latest](https://img.shields.io/pypi/v/aether-agent?label=&color=3775a9)](https://pypi.org/project/aether-agent/) | Launcher that installs and runs the npm CLI; it fetches the npm `latest` dist-tag unless you pin one. | -| `main` source build | **0.3.0** | Current repository source and its 0.3 workflow. | +| `main` source build | **0.3.2** | Current repository source: the 0.3 workflow plus every 0.3.1 maintenance fix. | The [release record](docs/releases/2026-08-22.md), [release notes](RELEASE_NOTES.md), and diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index cd0b1fb5..65501a43 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,31 @@ +# Aether Agent v0.3.2 — one line again + +**September 3, 2026** + +- **The v0.3.1 fixes are on `main`.** v0.3.1 was released from a branch that was + never merged, so `main` moved forward without its browser-launcher + verification and browser-login recovery path — while still declaring `0.3.0`, + a version older than the one on the registry. That line is merged, and the + declared version leads the published one again. +- **Browser sign-in reports what actually happened.** Opening the sign-in page + now waits for the operating system launcher to start or fail, so a machine + with no browser says so instead of reporting a launch it only attempted. +- **No new surface.** This patch adds no command, flag, or contract of its own. + +--- + +# Aether Agent v0.3.1 — first-run recovery + +**August 28, 2026** + +- **Clearer first-run recovery.** The CLI now stops before executing on an + unsupported Node.js runtime and prints the upgrade path. Browser-based sign-in + continues with the displayed URL and code when the system browser cannot open. +- **No feature expansion.** This is a compatibility patch over v0.3.0; it does + not include the later device-runtime work on `main`. + +--- + # Aether Agent v0.3.0 — durable work, verified delivery **Release candidate finalized August 27, 2026** diff --git a/docs/releases/2026-08-28.md b/docs/releases/2026-08-28.md new file mode 100644 index 00000000..61224c65 --- /dev/null +++ b/docs/releases/2026-08-28.md @@ -0,0 +1,7 @@ +# Aether Agent v0.3.1 — first-run recovery + +This patch makes two startup failures recoverable: an unsupported Node.js +runtime explains how to upgrade before command handling begins, and browser +sign-in keeps the displayed URL and code usable when a browser cannot be +opened. It intentionally excludes the later feature work already present on +`main`. diff --git a/docs/releases/2026-09-03.md b/docs/releases/2026-09-03.md new file mode 100644 index 00000000..f3d38faf --- /dev/null +++ b/docs/releases/2026-09-03.md @@ -0,0 +1,14 @@ +# Aether Agent v0.3.2 — one line again + +This patch reunites the v0.3.1 maintenance line with `main`. v0.3.1 was released +from a branch that was never merged, so `main` carried on without its +browser-launcher verification and browser-login recovery, while still declaring +`0.3.0` — a version behind the one the registry was serving. The merge lands +those fixes and takes the next patch number, so the source tree leads the +published version again. + +It adds no command, flag, or contract of its own. The device-runtime, telemetry, +and session-library work already on `main` is carried by the merge and is not +announced as new here. + +Packet: [OPERATOR-PACKET-v0.3.2.md](OPERATOR-PACKET-v0.3.2.md). diff --git a/docs/releases/OPERATOR-PACKET-v0.3.1.md b/docs/releases/OPERATOR-PACKET-v0.3.1.md new file mode 100644 index 00000000..b00d2062 --- /dev/null +++ b/docs/releases/OPERATOR-PACKET-v0.3.1.md @@ -0,0 +1,56 @@ +# Operator packet — Aether Agent v0.3.1 + +This packet records a narrowly scoped patch candidate. It is not a release +authorization: the tag, GitHub release, and npm publication remain prohibited +until the final pull-request commit has passed the required hosted checks. + +| | | +|---|---| +| Package | `aether-agents` | +| Proposed tag | `v0.3.1` | +| Release line base | `fb7ceb9c78fdacf84a864a07523185fb4387f531` (`v0.3.0`) | +| Candidate branch | `codex/patch-release-031` | +| Release scope | First-run recovery fixes only: early Node.js-version guidance and browser-login fallback messaging. | +| Deliberately excluded | Later feature work on `main`, including the device-runtime surface and other unreleased command changes. | +| Selected-change provenance | Equivalent, selectively applied portions of `44f1223f62e83c14e8d7741d8590ce61721e3fab` (first-run hardening), reviewed against v0.3.0. | +| Required qualification | Exact-head Windows, Linux, CodeQL, supply-chain, documentation, package, and release-truth checks; exact-package install canaries; then the release workflow’s trusted publication and provenance attestation. | +| Archive evidence | No release archive exists. The current local Windows `npm pack --dry-run --json --ignore-scripts` reported `aether-agents-0.3.1.tgz`, 837,371 packed bytes, 3,695,485 unpacked bytes, `shasum` `889864b707b5611eefa93461bde5f90757538731`, and integrity `sha512-wJjcMrxHQbdo7RfTciMD7evZJSt2wo1CNsrONNRI8w4zPeBILxNG+2/1WHaH7RVFeXz563gp1T4Bd0abpgTHqw==`; dry-run metadata is not a release checksum or provenance attestation. | +| Package manifest | The same current local dry run reported 618 entries. Hosted exact-head package evidence remains required before release. | +| Provenance evidence | Pending the trusted-publishing workflow — no v0.3.1 provenance attestation exists yet. | +| Rollback | Restore npm `latest` to `0.3.0` if a post-publication regression is confirmed; do not unpublish a released version. | + +## Version decision + +`0.3.0 → 0.3.1` is appropriate because the candidate changes recovery +messaging and failure handling without adding a public feature or changing a +supported command contract. The complete `v0.3.0...main` range is not a patch +candidate and is intentionally outside this release line. + +## Qualification record + +The final merge commit, workflow run IDs, artifact digests, package integrity, +SBOM, provenance attestation, and platform canary results are added only from +the corresponding immutable hosted evidence. Until then this candidate is not +publishable. + +## Commands retained without an explicit release-note invocation + +These existing visible commands are intentionally retained without adding a +new v0.3.1 announcement. The named exemptions are reviewed at each release so +the patch does not silently add a command surface: + +- `aether help` +- `aether chat` +- `aether run` +- `aether agents` +- `aether github` +- `aether vault` +- `aether workflow` +- `aether memory` +- `aether image` +- `aether video` +- `aether output` +- `aether audit` +- `aether receipt` +- `aether mcp` +- `aether config` diff --git a/docs/releases/OPERATOR-PACKET-v0.3.2.md b/docs/releases/OPERATOR-PACKET-v0.3.2.md new file mode 100644 index 00000000..0eec70e6 --- /dev/null +++ b/docs/releases/OPERATOR-PACKET-v0.3.2.md @@ -0,0 +1,60 @@ +# Operator packet — Aether Agent v0.3.2 + +This packet records the candidate that reunites the v0.3.1 maintenance line with +`main`. It is not a release authorization: the tag, GitHub release, and npm +publication remain prohibited until the final pull-request commit has passed the +required hosted checks. + +| | | +|---|---| +| Package | `aether-agents` | +| Proposed tag | `v0.3.2` | +| Release line base | `c4a16242ad117a499f91e3b531baa80f1a3ff0bd` (`v0.3.1`) | +| Candidate branch | `fix/reconcile-v031-into-main` | +| Release scope | Reconciliation. v0.3.1 was released from `codex/patch-release-031`, which was never merged, so `main` shipped forward without the browser-launcher verification (`openTargetChecked` / `openBrowserAwaitLaunch`), the async browser-login path, and the maintenance-line release guards — while still declaring `0.3.0`. This candidate merges that line into `main` and takes the next patch number, so the declared version is once again ahead of the published `latest`. | +| Deliberately excluded | No new feature work. The device-runtime, telemetry, and session-library work already on `main` is carried by the merge, not introduced here, and is not announced as new in this entry. | +| Selected-change provenance | Full merge of `v0.3.1` (`c4a1624`) into `main` at `5de5aa24c4b6e753affdcf46934499293a286dd0`. Five conflicts resolved: `README.md` and `test/login.test.ts` kept `main`'s rebuilt copy; `src/commands/login.ts` took the v0.3.1 async launcher path; `package.json` and `src/main.ts` were reconciled by hand. | +| Version-drift record | Before this candidate, npm `latest` served `0.3.1` while `main` declared `0.3.0`, so the source tree claimed a version older than the published one and lacked its fixes. `v0.3.1` is now an ancestor of `main`, and that class of drift is detectable by `git merge-base --is-ancestor`. | +| Required qualification | Exact-head Windows, Linux, CodeQL, supply-chain, documentation, package, and release-truth checks; exact-package install canaries; then the release workflow's trusted publication and provenance attestation. | +| Archive evidence | No release archive exists. The current local Windows `npm pack --dry-run --json --ignore-scripts` reported `aether-agents-0.3.2.tgz`, 1,387,500 packed bytes, 4,461,548 unpacked bytes, `shasum` `3e22664fe2bc647efe87a72a4d4fb95cb5566e83`, and integrity `sha512-AKvvYi40h64DvOylTk3qDKbs6k+MLP5kt3tUBtoQUDLcu+nTOiEnPxYDbmcmelynROKZjPVnsKxzlOCFJfodow==`; dry-run metadata is not a release checksum or provenance attestation. | +| Package manifest | The same current local dry run reported 673 entries. Hosted exact-head package evidence remains required before release. | +| Provenance evidence | Pending the trusted-publishing workflow — no v0.3.2 provenance attestation exists yet. | +| PyPI launcher | `packages/pypi-cli` publishes separately as `aether-agent` and is synced to this version by `node packages/sync-version.mjs`. It installs the npm `latest` dist-tag rather than its own version, so it is not a second copy of this release. | +| Rollback | Restore npm `latest` to `0.3.1` if a post-publication regression is confirmed; do not unpublish a released version. | + +## Version decision + +`0.3.1 → 0.3.2` is appropriate because this candidate publishes no new command, +flag, or contract of its own. It carries the v0.3.1 fixes onto `main` and +restores an ordering the registry already assumed. The feature work that has +accumulated on `main` is carried along by the merge but is not announced here; +whichever release chooses to announce it should take the next minor number. + +## Qualification record + +The final merge commit, workflow run IDs, artifact digests, package integrity, +SBOM, provenance attestation, and platform canary results are added only from +the corresponding immutable hosted evidence. Until then this candidate is not +publishable. + +## Commands retained without an explicit release-note invocation + +These existing visible commands are intentionally retained without adding a new +v0.3.2 announcement. The named exemptions are reviewed at each release so the +patch does not silently add a command surface: + +- `aether help` +- `aether chat` +- `aether run` +- `aether agents` +- `aether github` +- `aether vault` +- `aether workflow` +- `aether memory` +- `aether image` +- `aether video` +- `aether output` +- `aether audit` +- `aether receipt` +- `aether mcp` +- `aether config` diff --git a/docs/releases/README.md b/docs/releases/README.md index e51d7d46..95417568 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -12,6 +12,13 @@ manifest, the evidence gathered, and the founder-owned steps that publish it. ## Index +- [2026-09-03](2026-09-03.md) — **v0.3.2** patch candidate: the v0.3.1 + maintenance line merged back into `main`, restoring the browser-launcher + verification and putting the declared version ahead of the published one. + Packet: [OPERATOR-PACKET-v0.3.2.md](OPERATOR-PACKET-v0.3.2.md). +- [2026-08-28](2026-08-28.md) — **v0.3.1** patch candidate: actionable + Node.js-version recovery and browser-login fallback. Packet: + [OPERATOR-PACKET-v0.3.1.md](OPERATOR-PACKET-v0.3.1.md). - [v0.3.0 release body](RELEASE-BODY-v0.3.0.md) — publish-ready user-facing notes for the GitHub release. It deliberately makes no pre-publication npm claim; the dated publication record is added only after registry verification. diff --git a/package-lock.json b/package-lock.json index af3def03..da90f5b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aether-agents", - "version": "0.3.0", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aether-agents", - "version": "0.3.0", + "version": "0.3.2", "license": "Apache-2.0", "bin": { "aether": "dist/src/main.js", diff --git a/package.json b/package.json index 97ce3ba5..42112342 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aether-agents", - "version": "0.3.0", + "version": "0.3.2", "description": "Open-source terminal coding agent for hosted models or local Ollama, with repository-aware tools, operator-controlled execution, and verified results.", "type": "module", "bin": { diff --git a/packages/pypi-cli/pyproject.toml b/packages/pypi-cli/pyproject.toml index 6f9437cb..0254e378 100644 --- a/packages/pypi-cli/pyproject.toml +++ b/packages/pypi-cli/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "hatchling.build" # fails the build if they drift. [project] name = "aether-agent" -version = "0.3.0" +version = "0.3.2" description = "Install and run Aether Agent, the open-source terminal coding agent, from pip or pipx." readme = "README.md" requires-python = ">=3.10" diff --git a/packages/pypi-cli/src/aether_agent/__init__.py b/packages/pypi-cli/src/aether_agent/__init__.py index 48cd3c9d..2b8dc098 100644 --- a/packages/pypi-cli/src/aether_agent/__init__.py +++ b/packages/pypi-cli/src/aether_agent/__init__.py @@ -13,7 +13,7 @@ from __future__ import annotations -__version__ = "0.3.0" +__version__ = "0.3.2" #: The npm package this launcher installs and runs. NPM_PACKAGE = "aether-agents" diff --git a/scripts/verify-production.ts b/scripts/verify-production.ts index 99f3199e..3c2da5e7 100644 --- a/scripts/verify-production.ts +++ b/scripts/verify-production.ts @@ -192,6 +192,14 @@ export function validateWorkflowText(name: string, text: string): string[] { if (!/git merge-base --is-ancestor HEAD origin\/main/.test(text)) { errors.push(`${name}: release commit ancestry to main must be verified`); } + if (/origin\/release\/0\.3/.test(text)) { + if (!text.includes('[[ "$RELEASE_TAG" =~ ^v0\\.3\\.[0-9]+$ ]]')) { + errors.push(`${name}: the release/0.3 exception must be restricted to numeric v0.3.x tags`); + } + if (!text.includes('test "$(git rev-parse HEAD)" = "$(git rev-parse origin/release/0.3)"')) { + errors.push(`${name}: v0.3.x publication must equal the exact release/0.3 head`); + } + } if (!/npm install --global[^\n]*--ignore-scripts/.test(text)) { errors.push(`${name}: the exact tarball must pass an install smoke test`); } diff --git a/src/commands/login.ts b/src/commands/login.ts index ed58b7d0..9e732e9d 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -11,7 +11,7 @@ import type { AppContext } from "../core/context.js"; import { loginWithPassword } from "../core/auth.js"; import { LOGOUT_PATH } from "../core/transport.js"; import { requestDeviceCode, pollForToken } from "../core/device.js"; -import { openBrowserChecked } from "../core/browser.js"; +import { openBrowserAwaitLaunch } from "../core/browser.js"; import type { OpenOutcome } from "../core/opener.js"; import { theme } from "../ui/theme.js"; import { errorHint, errorMessage } from "../core/errors.js"; @@ -27,10 +27,10 @@ export interface LoginOpts { } export interface LoginDependencies { - openBrowser: (url: string) => OpenOutcome; + openBrowser: (url: string) => OpenOutcome | Promise; } -const LOGIN_DEPENDENCIES: LoginDependencies = { openBrowser: openBrowserChecked }; +const LOGIN_DEPENDENCIES: LoginDependencies = { openBrowser: openBrowserAwaitLaunch }; /** After a successful login, flag a shell-level AETHER_TOKEN: it is re-read by * every NEW process and would shadow the token just stored — the classic @@ -113,7 +113,7 @@ export async function cmdLogin( if (opts.noBrowser) { process.stdout.write("Browser not opened (--no-browser); use the URL and code above.\n"); } else { - const opened = dependencies.openBrowser(code.verification_uri_complete); + const opened = await dependencies.openBrowser(code.verification_uri_complete); if (opened.status !== "spawned") { process.stderr.write( `⚠ Browser was not opened (${opened.status}). Use the URL and code above, ` + diff --git a/src/core/browser.ts b/src/core/browser.ts index 02f56e8a..803c53a2 100644 --- a/src/core/browser.ts +++ b/src/core/browser.ts @@ -8,7 +8,7 @@ // `cmd /c start "" ` on Windows, which handed the URL to the command // interpreter as a token. -import { openTarget, type OpenOutcome } from "./opener.js"; +import { openTarget, openTargetChecked, type OpenOutcome } from "./opener.js"; /** Open `url` in the default browser. Never throws. */ export function openBrowser(url: string): void { @@ -19,3 +19,9 @@ export function openBrowser(url: string): void { export function openBrowserChecked(url: string): OpenOutcome { return openTarget(url); } + +/** Wait for the initial OS launcher outcome when an interactive recovery path + * needs to tell the user whether the browser was actually started. */ +export function openBrowserAwaitLaunch(url: string): Promise { + return openTargetChecked(url); +} diff --git a/src/core/opener.ts b/src/core/opener.ts index 937027e6..469344ec 100644 --- a/src/core/opener.ts +++ b/src/core/opener.ts @@ -183,7 +183,9 @@ export function openTarget(target: string, options: OpenOptions = {}): OpenOutco detached: true, shell: false, }); - // ENOENT (no xdg-open on a minimal container) arrives asynchronously. + // This synchronous API only reports whether spawning was attempted. Callers + // that need to distinguish a missing launcher from a launched browser must + // use openTargetChecked below, which waits for spawn or error. child.on("error", () => {}); child.unref(); return plan; @@ -196,3 +198,43 @@ export function openTarget(target: string, options: OpenOptions = {}): OpenOutco }; } } + +/** + * Open a target and wait until the OS launcher either starts or reports its + * initial launch failure. This still does not wait for the browser itself: + * once the launcher emits `spawn`, it is detached and can outlive the CLI. + */ +export async function openTargetChecked(target: string, options: OpenOptions = {}): Promise { + const plan = planOpen(target, options); + if (plan.status !== "spawned") return plan; + + const launcher = options.spawnFn ?? spawn; + try { + const child = launcher(plan.executable!, [...plan.args!], { + stdio: "ignore", + detached: true, + shell: false, + }); + return await new Promise((resolve) => { + child.once("error", (err) => { + resolve({ + status: "spawn-error", + executable: plan.executable, + args: plan.args, + detail: err instanceof Error ? err.message : "opener could not be launched", + }); + }); + child.once("spawn", () => { + child.unref(); + resolve(plan); + }); + }); + } catch (err) { + return { + status: "spawn-error", + executable: plan.executable, + args: plan.args, + detail: err instanceof Error ? err.message : "opener could not be launched", + }; + } +} diff --git a/src/version.ts b/src/version.ts index b3510d2e..03be45b3 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Single source of the CLI version (kept in lockstep with package.json). -export const VERSION = "0.3.0"; +export const VERSION = "0.3.2"; diff --git a/test/main_entrypoint.test.ts b/test/main_entrypoint.test.ts index 8fd76b2b..eda1abe7 100644 --- a/test/main_entrypoint.test.ts +++ b/test/main_entrypoint.test.ts @@ -2,7 +2,20 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { isMainInvocation } from "../src/main.js"; +import { isMainInvocation, MINIMUM_NODE_MAJOR, unsupportedNodeMessage } from "../src/main.js"; + +test("unsupported Node versions provide one copyable recovery path", () => { + assert.equal(MINIMUM_NODE_MAJOR, 24); + assert.equal(unsupportedNodeMessage("24.0.0"), null); + assert.equal(unsupportedNodeMessage("v26.1.0"), null); + for (const version of ["23.9.0", "v20.0.0", "", "not-a-version"]) { + const message = unsupportedNodeMessage(version); + assert.ok(message); + assert.match(message, /requires Node\.js >= 24/); + assert.match(message, /reopen your terminal.*aether --version/s); + assert.match(message, /nodejs\.org\/en\/download/); + } +}); test("installed npm bin symlink is recognized as the CLI entrypoint", () => { const shim = resolve("virtual", "bin", "aether"); diff --git a/test/opener.test.ts b/test/opener.test.ts index 4715f87b..1c416336 100644 --- a/test/opener.test.ts +++ b/test/opener.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,6 +8,7 @@ import { isAllowedUrl, looksLikeUrl, openTarget, + openTargetChecked, planOpen, resolveOpenCommand, } from "../src/core/opener.js"; @@ -228,6 +230,20 @@ test("a spawn failure is reported, not thrown", () => { assert.match(outcome.detail, /ENOENT/); }); +test("openTargetChecked reports a real asynchronous ENOENT from a missing Linux launcher", async () => { + const outcome = await openTargetChecked("https://example.invalid/device", { + platform: "linux", + env: DESKTOP_ENV, + // Keep the production xdg-open selection, but replace this test process's + // executable with one guaranteed not to exist so Node emits the real async + // ENOENT path rather than a synthetic thrown error. + spawnFn: ((_: string, args: readonly string[], options: Record) => + spawn("aether-test-missing-xdg-open", [...args], options as Parameters[2])) as never, + }); + assert.equal(outcome.status, "spawn-error"); + assert.match(outcome.detail, /ENOENT|not found/i); +}); + test("planOpen validates without launching so doctor can report read-only", () => { const dir = sandbox(); const file = join(dir, "a.png"); diff --git a/test/production_hardening.test.ts b/test/production_hardening.test.ts index 7c71ae4f..953f214e 100644 --- a/test/production_hardening.test.ts +++ b/test/production_hardening.test.ts @@ -139,6 +139,20 @@ test("publishing workflow must be event-gated, main-derived, and install-smoked" assert.match(errors, /install smoke/); }); +test("a v0.3 maintenance release must use a numeric tag at the exact protected branch head", () => { + const release = readFileSync(join(process.cwd(), ".github", "workflows", "release.yml"), "utf8"); + assert.deepEqual(validateWorkflowText("release.yml", release), []); + + const looseTag = release.replace('[[ "$RELEASE_TAG" =~ ^v0\\.3\\.[0-9]+$ ]]', '[[ "$RELEASE_TAG" == v0.3.* ]]'); + assert.match(validateWorkflowText("release.yml", looseTag).join("\n"), /numeric v0\.3\.x tags/); + + const looseHead = release.replace( + 'test "$(git rev-parse HEAD)" = "$(git rev-parse origin/release/0.3)"', + 'git merge-base --is-ancestor HEAD origin/release/0.3', + ); + assert.match(validateWorkflowText("release.yml", looseHead).join("\n"), /exact release\/0\.3 head/); +}); + test("installer policy rejects pipe-to-shell and lifecycle-enabled global installs", () => { assert.deepEqual(validateInstallerText("safe.sh", "npm install -g aether-agents@latest --ignore-scripts"), []); assert.match(validateInstallerText("bad.sh", "curl https://example.test/install.sh | sh\nnpm install -g aether-agents").join("\n"), /pipe-to-shell/); diff --git a/test/release_coherence.test.ts b/test/release_coherence.test.ts index 9776ddd3..36210e84 100644 --- a/test/release_coherence.test.ts +++ b/test/release_coherence.test.ts @@ -59,6 +59,15 @@ const EXPECTED_HOSTED_WINDOWS_UNPACKED = 3_690_927; const EXPECTED_LOCAL_WINDOWS_UNPACKED = 3_690_927; const EXPECTED_LINUX_PACKED = 835_957; const EXPECTED_WINDOWS_PACKED = 836_234; +const V032_PACKET = { + "Package": "`aether-agents`", + "Proposed tag": "`v0.3.2`", + "Release line base": "`c4a16242ad117a499f91e3b531baa80f1a3ff0bd` (`v0.3.1`)", + "Candidate branch": "`fix/reconcile-v031-into-main`", + "Archive evidence": "No release archive exists. The current local Windows `npm pack --dry-run --json --ignore-scripts` reported `aether-agents-0.3.2.tgz`, 1,387,500 packed bytes, 4,461,548 unpacked bytes, `shasum` `3e22664fe2bc647efe87a72a4d4fb95cb5566e83`, and integrity `sha512-AKvvYi40h64DvOylTk3qDKbs6k+MLP5kt3tUBtoQUDLcu+nTOiEnPxYDbmcmelynROKZjPVnsKxzlOCFJfodow==`; dry-run metadata is not a release checksum or provenance attestation.", + "Package manifest": "The same current local dry run reported 673 entries. Hosted exact-head package evidence remains required before release.", + "Provenance evidence": "Pending the trusted-publishing workflow — no v0.3.2 provenance attestation exists yet.", +} as const; function parsePacketRows(packet: string): PacketRows { const rows: PacketRows = new Map(); @@ -79,7 +88,7 @@ function onlyPacketRow(rows: PacketRows, label: string): string { return values[0]!; } -function assertPacketProvenance(packet: string): PacketRows { +function assertV030PacketProvenance(packet: string): PacketRows { const rows = parsePacketRows(packet); const baseLabels = [...rows.keys()].filter((label) => /base/i.test(label)).sort(); assert.deepEqual( @@ -185,12 +194,18 @@ test("the dated release log has an entry for this version, and the index links i } }); -test("the operator packet separates final-candidate, pre-merge, and historical archives", () => { +test("the current operator packet identifies the candidate and v0.3.0 retains its historical provenance", () => { const path = join(root, "docs", "releases", `OPERATOR-PACKET-v${VERSION}.md`); assert.ok(existsSync(path), `no docs/releases/OPERATOR-PACKET-v${VERSION}.md`); - const packet = readFileSync(path, "utf8"); - assert.ok(packet.includes(`v${VERSION}`), "the operator packet does not name the proposed tag"); - assertPacketProvenance(packet); + const current = readFileSync(path, "utf8"); + assert.ok(current.includes(`v${VERSION}`), "the operator packet does not name the proposed tag"); + const currentRows = parsePacketRows(current); + for (const [label, expected] of Object.entries(V032_PACKET)) { + assert.equal(onlyPacketRow(currentRows, label), expected, `the current packet has the wrong ${label}`); + } + assert.doesNotMatch(current, /aether-agents-0\.3\.0\.tgz|6176172deb15eea57519408d93f23b3fac8ab5e2b2e541adddc34b4e5fb4c33d/); + const packet = read("docs", "releases", "OPERATOR-PACKET-v0.3.0.md"); + assertV030PacketProvenance(packet); assert.match(packet, /\| Qualified final-candidate archive \| `aether-agents-0\.3\.0\.tgz` — 835,957 bytes packed \/ 3,688,966 unpacked \/ 618 entries at `1271457\.\.\.`;/); assert.match(packet, /\| Qualified final-candidate archive sha256 \| `6176172deb15eea57519408d93f23b3fac8ab5e2b2e541adddc34b4e5fb4c33d` \|/); assert.match(packet, /\| Qualified pre-merge archive \| `aether-agents-0\.3\.0\.tgz` — 835,957 bytes packed \/ 3,688,966 unpacked \/ 618 entries at `3cf44bb\.\.\.`;/); @@ -201,7 +216,7 @@ test("the operator packet separates final-candidate, pre-merge, and historical a }); test("the operator packet fails closed on contradictory release-evidence mutations", () => { - const packet = read("docs", "releases", `OPERATOR-PACKET-v${VERSION}.md`); + const packet = read("docs", "releases", "OPERATOR-PACKET-v0.3.0.md"); const mutations = [ [ "duplicate reconciled base", @@ -236,16 +251,16 @@ test("the operator packet fails closed on contradictory release-evidence mutatio for (const [name, mutant] of mutations) { assert.notEqual(mutant, packet, `${name} mutation did not alter the packet fixture`); - assert.throws(() => assertPacketProvenance(mutant), name); + assert.throws(() => assertV030PacketProvenance(mutant), name); } }); test( - "the operator packet's final-candidate manifest keeps matching the package membership", + "the v0.3.0 operator packet's final-candidate manifest keeps matching the package membership", { timeout: 120_000 }, () => { - const packet = read("docs", "releases", `OPERATOR-PACKET-v${VERSION}.md`); - assertPacketProvenance(packet); + const packet = read("docs", "releases", "OPERATOR-PACKET-v0.3.0.md"); + assertV030PacketProvenance(packet); const packed = currentPackReport(); const header = /\| Current exact-head dry run \| ([\d,]+) entries \/ ([\d,]+) workflows \|/.exec(packet); assert.ok(header, "the operator packet has no parseable current dry-run summary");