diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bac1da3..9db7bdb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -459,7 +459,7 @@ jobs: demos: name: Native demos - needs: [changes, native] + needs: [changes, native-parity-build] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-24.04 timeout-minutes: 20 @@ -476,17 +476,21 @@ jobs: - run: bun install --frozen-lockfile - uses: actions/download-artifact@v8 with: - name: algal-debug-linux + name: algal-ci-parity-linux path: ${{ runner.temp }} - - name: Restore the debug binary built by the native job - run: mkdir -p target/debug && tar -C target/debug -xf "$RUNNER_TEMP/algal-debug.tar" && target/debug/algal --version - - run: ALGAL_MEMORY_NATIVE="$PWD/target/debug/algal" bun test examples/coding-harness/memory-records.test.ts examples/coding-harness/memory.test.ts - - run: ALGAL_MEMORY_NATIVE="$PWD/target/debug/algal" bun test examples/adaptive-inventory/run.test.ts - - run: bun scripts/vm-demo.ts --native ./target/debug/algal - - run: bun scripts/recovery-demo.ts --native ./target/debug/algal - - run: bun scripts/repair-demo.ts --native ./target/debug/algal - - run: bun scripts/coding-recovery-demo.ts --native ./target/debug/algal - - run: bun scripts/process-evidence-demo.ts --native ./target/debug/algal + # SDK children verify their executable hash on every invocation. Reuse + # the existing optimized binary while retaining debug assertions and + # overflow checks, so hashing full debug symbols cannot consume a demo's + # unchanged process deadline. + - name: Restore the ci-parity binary + run: mkdir -p target/ci-parity && tar -C target/ci-parity -xf "$RUNNER_TEMP/algal-ci-parity.tar" && target/ci-parity/algal --version + - run: ALGAL_MEMORY_NATIVE="$PWD/target/ci-parity/algal" bun test examples/coding-harness/memory-records.test.ts examples/coding-harness/memory.test.ts + - run: ALGAL_MEMORY_NATIVE="$PWD/target/ci-parity/algal" bun test examples/adaptive-inventory/run.test.ts + - run: bun scripts/vm-demo.ts --native ./target/ci-parity/algal + - run: bun scripts/recovery-demo.ts --native ./target/ci-parity/algal + - run: bun scripts/repair-demo.ts --native ./target/ci-parity/algal + - run: bun scripts/coding-recovery-demo.ts --native ./target/ci-parity/algal + - run: bun scripts/process-evidence-demo.ts --native ./target/ci-parity/algal required: name: Required diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f84b9688..44d7b602 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: required: true type: string publish: - description: Attach verified assets to a draft or prerelease (create a prerelease if absent) + description: Stage and verify complete assets in a draft, then publish an immutable prerelease required: true default: false type: boolean @@ -104,6 +104,7 @@ jobs: - name: Build locked release executable env: CARGO_BUILD_TARGET: ${{ matrix.target }} + ALGAL_RELEASE_BUILD_TAG: ${{ inputs.tag }} run: cargo +1.97.1 build --release --locked -p algal --bin algal - name: Qualify archive and installer env: @@ -165,6 +166,7 @@ jobs: - name: Build and qualify the unsigned executable env: CARGO_BUILD_TARGET: aarch64-apple-darwin + ALGAL_RELEASE_BUILD_TAG: ${{ inputs.tag }} run: | cargo +1.97.1 build --release --locked -p algal --bin algal python3 scripts/test-native-release.py --binary target/aarch64-apple-darwin/release/algal --commit "$GITHUB_SHA" --rustc-version "$(rustc +1.97.1 --version)" @@ -398,37 +400,4 @@ jobs: notes="$RUNNER_TEMP/release-body.md" title=$(python3 scripts/release-notes.py title --tag "$RELEASE_TAG") python3 scripts/release-notes.py render --tag "$RELEASE_TAG" --commit "$RELEASE_SHA" --manifests artifacts --out "$notes" - check_page() { - gh release view "$RELEASE_TAG" --json name,body > "$RUNNER_TEMP/release-page.json" - python3 - "$RUNNER_TEMP/release-page.json" "$RUNNER_TEMP/release-page.md" "$title" <<'PYPAGE' - import json, pathlib, sys - page = json.loads(pathlib.Path(sys.argv[1]).read_text()) - if page["name"] != sys.argv[3]: - raise SystemExit("Release title differs from the product name and tag") - pathlib.Path(sys.argv[2]).write_bytes(page["body"].encode()) - PYPAGE - python3 scripts/release-notes.py verify --tag "$RELEASE_TAG" --commit "$RELEASE_SHA" --manifests artifacts --body "$RUNNER_TEMP/release-page.md" - } - if gh release view "$RELEASE_TAG" --json isDraft,isPrerelease,body > "$RUNNER_TEMP/release.json"; then - existing=$(python3 - "$RUNNER_TEMP/release.json" <<'PYEXISTING' - import json, sys - r = json.load(open(sys.argv[1])) - assert r["isDraft"] or r["isPrerelease"], "Refusing to mutate a stable release" - print("page" if "")) self.assertEqual(len(release["assets"]), 3 * len(notes.TARGETS)) - self.assertIn("create", [call[1] for call in calls if call[0] == "release"]) + self.assertTrue(any(call[3] == "repos/hraness/algal/releases" and "POST" in call for call in self.mutations(calls))) + self.assertTrue(release["immutable"]) + self.assertFalse(release["isDraft"]) + uploaded = max(i for i, call in enumerate(calls) if call[0] == "api" and call[3].startswith("https://uploads.github.com/")) + published = next(i for i, call in enumerate(calls) if call[0] == "fixture-patch" and call[1].get("draft") is False) + self.assertLess(uploaded, published) + self.assertTrue(any(call[:3] == ["api", "--hostname", "github.com"] and call[3] == "repos/hraness/algal/releases/42" + for call in calls[uploaded + 1:published])) + self.assertFalse(any(call[0] == "api" and "/releases/tags/" in call[3] for call in calls)) + + def test_uploaded_digest_mismatch_keeps_the_release_draft(self): + result, release, calls = self.run_publish(bad_asset=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("asset bytes differ", result.stderr) + self.assertTrue(release["isDraft"]) + self.assertFalse(any(call[0] == "fixture-patch" and call[1].get("draft") is False for call in calls)) def test_missing_or_unreleased_section_fails_before_any_release_call(self): for changelog in [CHANGELOG.replace(f"## {TAG} - 2026-10-01", "## v9.9.9"), @@ -309,19 +300,21 @@ def test_owner_draft_without_identity_gets_rendered_page(self): def test_retry_with_matching_page_uploads(self): first, release, _ = self.run_publish() self.assertEqual(first.returncode, 0, first.stderr) - result, release, calls = self.run_publish(release={**release, "assets": []}) + result, release, calls = self.run_publish(release={**release, "isDraft": True, "immutable": False, "assets": [], "assetProofs": {}}) self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse([call for call in calls if call[:2] in (["release", "create"], ["release", "edit"])]) + self.assertFalse(any(call[3] == "repos/hraness/algal/releases" for call in self.mutations(calls))) + patches = [call[1] for call in calls if call[0] == "fixture-patch"] + self.assertEqual(patches, [{"draft": False, "prerelease": True, "make_latest": "false"}]) def test_hand_edited_page_is_refused(self): first, release, _ = self.run_publish() self.assertEqual(first.returncode, 0, first.stderr) - edited = {**release, "assets": [], "body": release["body"].replace("64 KiB", "32 KiB")} + edited = {**release, "isDraft": True, "immutable": False, "assets": [], "body": release["body"].replace("64 KiB", "32 KiB")} result, after, calls = self.run_publish(release=edited) self.assertNotEqual(result.returncode, 0) self.assertIn("differ", result.stderr) self.assertEqual(after["assets"], []) - self.assertFalse([call for call in calls if call[:2] == ["release", "upload"]]) + self.assertFalse(self.mutations(calls)) def test_body_changed_after_publication_fails_the_run(self): result, _, _ = self.run_publish(mangle=True) @@ -333,6 +326,63 @@ def test_stable_release_is_never_mutated(self): result, release, calls = self.run_publish(release=stable) self.assertNotEqual(result.returncode, 0) self.assertEqual(release, stable) + self.assertFalse(self.mutations(calls)) + + def test_published_prerelease_is_never_mutated(self): + for immutable in (False, True): + with self.subTest(immutable=immutable): + published = {"isDraft": False, "isPrerelease": True, "immutable": immutable, "name": "x", "body": "x", "assets": []} + result, release, calls = self.run_publish(release=published) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(release, published) + self.assertFalse(self.mutations(calls)) + + def test_partial_draft_preserves_existing_assets_and_uploads_only_missing(self): + first, release, _ = self.run_publish() + self.assertEqual(first.returncode, 0, first.stderr) + retained = release["assets"][:2] + draft = {**release, "isDraft": True, "immutable": False, "assets": retained, + "assetProofs": {name: release["assetProofs"][name] for name in retained}, + "payloads": {name: release["payloads"][name] for name in retained}} + result, published, calls = self.run_publish(release=draft) + self.assertEqual(result.returncode, 0, result.stderr) + uploads = [call for call in self.mutations(calls) if call[3].startswith("https://uploads.github.com/")] + self.assertEqual(len(uploads), 7) + for name in retained: + self.assertEqual(published["assetProofs"][name], release["assetProofs"][name]) + + def test_matching_published_release_is_read_only_after_uncertain_response(self): + first, release, _ = self.run_publish(extra_env={"FAKE_GH_PUBLISH_UNCERTAIN": "1"}) + self.assertNotEqual(first.returncode, 0) + self.assertTrue(release["immutable"]) + second, after, calls = self.run_publish(release=release) + self.assertEqual(second.returncode, 0, second.stderr) + self.assertEqual(after, release) + self.assertFalse(self.mutations(calls)) + + def test_conflicting_existing_asset_is_preserved_without_mutation(self): + first, release, _ = self.run_publish() + self.assertEqual(first.returncode, 0, first.stderr) + release["isDraft"] = True + release["immutable"] = False + release["assetProofs"][release["assets"][0]]["digest"] = "sha256:" + "0" * 64 + result, after, calls = self.run_publish(release=release) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(after, release) + self.assertFalse(self.mutations(calls)) + + def test_download_digest_mismatch_keeps_the_release_draft(self): + result, release, calls = self.run_publish(extra_env={"FAKE_GH_BAD_DOWNLOAD": "1"}) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(release["isDraft"]) + self.assertFalse(any(call[0] == "fixture-patch" and call[1].get("draft") is False for call in calls)) + + def test_release_inventory_has_a_finite_page_bound(self): + result, release, calls = self.run_publish(extra_env={"FAKE_GH_FULL_PAGES": "1"}) + self.assertNotEqual(result.returncode, 0) + self.assertIsNone(release) + self.assertEqual(sum(call[0] == "api" and "/releases?" in call[3] for call in calls), 10) + self.assertFalse(self.mutations(calls)) if __name__ == "__main__": diff --git a/site/install.sh b/site/install.sh index 3828dead..2a4ce434 100644 --- a/site/install.sh +++ b/site/install.sh @@ -54,6 +54,7 @@ main() { name="algal-$tag-$target" temporary=$(mktemp -d "${TMPDIR:-/tmp}/algal-install.XXXXXX") + temporary=$(cd "$temporary" && pwd -P) trap 'rm -rf "$temporary"' EXIT trap 'exit 1' HUP INT TERM @@ -122,6 +123,23 @@ main() { had_algal=0 [ -e "$bin/algal" ] && had_algal=1 mkdir -p "$bin" + prefix=$(cd "$prefix" && pwd -P) + bin="$prefix/bin" + + modern=true + case "$tag" in v0.0.*|v0.1.*|v0.2.0-vm.[1-9]|v0.2.0-vm.10|v0.2.0-vm.11|v0.2.0-vm.12) modern=false ;; esac + if [ "$modern" = true ] && [ -z "${ALGAL_DOWNLOAD_BASE:-}" ]; then + # The verified candidate independently verifies the immutable public release + # and performs final writes, rollback, and record publication under its lock. + if [ -n "${ALGAL_VERSION:-}" ]; then + "$staged" __install-release --archive "$temporary/$name.tar.gz" --checksum "$temporary/$name.tar.gz.sha256" --prefix "$prefix" --pinned + else + "$staged" __install-release --archive "$temporary/$name.tar.gz" --checksum "$temporary/$name.tar.gz.sha256" --prefix "$prefix" + fi + else + if [ -e "$bin/.hraness-cli-update-algal" ] || [ -L "$bin/.hraness-cli-update-algal" ]; then + fail "this installation uses native updates; use algal update or choose a new ALGAL_INSTALL_PREFIX" + fi # Keep the release record keyed by the executable's digest, so `algal doctor` # reports which release it came from. Never replace a different record. @@ -142,6 +160,7 @@ main() { cp "$staged" "$bin/.algal-install.$$" chmod 755 "$bin/.algal-install.$$" mv -f "$bin/.algal-install.$$" "$bin/algal" + fi echo "Installed $version at $bin/algal" case ":${PATH:-}:" in diff --git a/src/application-native-memory-update.test.ts b/src/application-native-memory-update.test.ts new file mode 100644 index 00000000..4f18fd89 --- /dev/null +++ b/src/application-native-memory-update.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NativeMemoryQueryEngine } from "./application-native-memory"; + +test.skipIf(process.platform === "win32")("the existing SDK pin reaches the child and automatic updates stay disabled", async () => { + const directory = await mkdtemp(join(tmpdir(), "algal-pinned-update-")); + try { + const executable = join(directory, "fixture"); + const script = '#!/bin/sh\nprintf "%s\\n" "$HRANESS_NO_UPDATE" "$ALGAL_EXPECTED_BINARY_SHA256" > "$0.env"\nprintf "{}\\n"\n'; + await writeFile(executable, script); await chmod(executable, 0o755); + const expectedSha256 = createHash("sha256").update(script).digest("hex"); + const engine = new NativeMemoryQueryEngine({executable, expectedSha256}); + const snapshot = {contract: "algal.memory.v1", facts: []}; + const program = {contract: "algal.query.v1", rules: [], query: {relation: "available", terms: [{var: "x"}]}, limits: {maxWork: 100, maxRounds: 2, maxDerived: 2, maxBindings: 2, maxRows: 2, maxOutputBytes: 4096}}; + // The fixture only records transport settings; it deliberately has no + // logical query implementation and its response is rejected normally. + await engine.query(snapshot, program).catch(() => undefined); + await engine.settle(); + expect(await readFile(`${executable}.env`, "utf8")).toBe(`1\n${expectedSha256}\n`); + await writeFile(executable, script + "# changed bytes\n"); + await expect(engine.query(snapshot, program)).rejects.toThrow("Pinned native memory executable changed"); + await engine.settle(); + } finally { await rm(directory, {recursive: true, force: true}); } +}); diff --git a/src/application-native-memory.ts b/src/application-native-memory.ts index ccb06afb..d5a4d213 100644 --- a/src/application-native-memory.ts +++ b/src/application-native-memory.ts @@ -71,7 +71,10 @@ export class NativeMemoryQueryEngine implements MemoryQueryEngine { const args = [this.executable, "memory", command, join(dir, "snapshot.json"), join(dir, "program.json")]; if (result !== undefined) { await writeFile(join(dir, "result.json"), canonicalize(result), { flag: "wx", mode: 0o600 }); args.push(join(dir, "result.json")); } if (signal?.aborted) return { failure: { kind: "incomplete", status: "cancelled", reason: "cancelled-before-dispatch", work: null } }; - const child = Bun.spawn(args, { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + const child = Bun.spawn(args, { + stdin: "ignore", stdout: "pipe", stderr: "pipe", + env: { ...process.env, HRANESS_NO_UPDATE: "1", ALGAL_EXPECTED_BINARY_SHA256: this.expectedSha256 }, + }); let stopReason: "cancelled" | "timeout" | "output-limit" | undefined; let force: ReturnType | undefined; const stop = (reason: typeof stopReason) => { diff --git a/src/cli-golden/help.txt b/src/cli-golden/help.txt index f2e42802..52d6c660 100644 --- a/src/cli-golden/help.txt +++ b/src/cli-golden/help.txt @@ -36,6 +36,7 @@ Store and share Setup auth jev Save a TypeSafe Jev key on this computer doctor Check that ALGAL is ready + update Show this Bun runtime's manual update workflow Options -h, --help Show help. Also: algal --help diff --git a/src/cli-help.ts b/src/cli-help.ts index 8911d7b2..89df3f2c 100644 --- a/src/cli-help.ts +++ b/src/cli-help.ts @@ -59,6 +59,7 @@ const GROUPS: readonly Group[] = [ rows: [ ["auth jev", "Save a TypeSafe Jev key on this computer"], ["doctor", "Check that ALGAL is ready"], + ["update", "Show this Bun runtime's manual update workflow"], ], }, ]; diff --git a/src/cli-update.test.ts b/src/cli-update.test.ts new file mode 100644 index 00000000..b02057ea --- /dev/null +++ b/src/cli-update.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +test("Bun update reports its manual workflow without creating a store or install state", async () => { + const cwd = await mkdtemp(join(tmpdir(), "algal-source-update-")); + try { + const command = Bun.spawn([process.execPath, join(import.meta.dir, "../cli.ts"), "update", "status", "--json"], {cwd, stdout: "pipe", stderr: "pipe"}); + const report = await new Response(command.stdout).json() as Record; + expect(await command.exited).toBe(0); + expect(report.status).toBe("unsupported"); + expect(report.supported).toBe(false); + expect(report.instructions).toContain("stays on Bun"); + expect(await readdir(cwd)).toEqual([]); + } finally { await rm(cwd, {recursive: true, force: true}); } +});