From 6ccebe63d1dc116ad056ac0071ae909f54e8a0d3 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 15 Nov 2026 09:28:43 +0200 Subject: [PATCH] ci(release): take the version as the run's input, not a bump commit A release needed a pull request that bumped scient.json, then a second CI run on main, before the release run could start. Scient Desktop takes the version as the release run's input and writes it into the build's checkout without committing it; the agent now does the same. The release run takes an optional version and otherwise releases the next patch after the latest published release. Its first job checks it before any build starts, each build stamps it into its own scient.json and checks the binary reports it, and the release job checks it again before drafting. Scient-Divergence: SA-RELEASE Co-Authored-By: Claude Opus 5.5 --- .github/workflows/scient.yml | 56 +++++++++++++------ SCIENT.md | 14 ++--- packages/utils/src/scient/product.ts | 3 +- scripts/scient/release-version.test.ts | 22 +++++++- scripts/scient/release-version.ts | 76 ++++++++++++++++++++------ 5 files changed, 130 insertions(+), 41 deletions(-) diff --git a/.github/workflows/scient.yml b/.github/workflows/scient.yml index 1e80b8c50f..8cff27767e 100644 --- a/.github/workflows/scient.yml +++ b/.github/workflows/scient.yml @@ -5,11 +5,13 @@ name: Scient Agent # from a stock omp. The macOS builds are signed on Linux by upstream's # scripts/ci-macos-sign.sh, ad hoc on every run so the signing path is always # exercised, then checked on macOS. A manual run on main with `release` set -# builds scient.json's version, signs the macOS builds with the Developer ID, -# notarizes them and, once every job passed, drafts a GitHub release with every -# binary and its checksum; a person publishes the draft, which creates the tag. -# As in Scient Desktop's release, a version is used up only when it is -# published: after a failed or superseded run, run again with the same version. +# builds the version it is given (or the next patch after the latest published +# release), signs the macOS builds with the Developer ID, notarizes them and, +# once every job passed, drafts a GitHub release with every binary and its +# checksum; a person publishes the draft, which creates the tag. As in Scient +# Desktop's release, the version is the run's input and is never committed, and +# a version is used up only when it is published: after a failed or superseded +# run, run again with the same version. # A manual run can also sign and notarize without drafting a release, to # qualify the signing secrets. Oh My Pi's own test suite runs on Linux against # Scient's tree, as upstream's CI runs it. @@ -22,9 +24,13 @@ on: workflow_dispatch: inputs: release: - description: Release scient.json's version from main (it must come right after the latest published release) + description: Release from main and draft the release type: boolean default: false + version: + description: "Release: x.y.z right after the latest published release. Blank releases the next patch" + type: string + default: "" developer-id: description: Sign the macOS builds with the Developer ID and notarize them (no release is drafted) type: boolean @@ -50,19 +56,20 @@ defaults: shell: bash jobs: - # A release builds scient.json's version only if it comes right after the - # latest published release and is not tagged yet (scripts/scient/release-version.ts). - # The release job checks again before it drafts. + # A release builds the version it is given, or the next patch, only if it + # comes right after the latest published release and is not tagged yet + # (scripts/scient/release-version.ts). The release job checks again before it + # drafts. The job runs on every run, doing nothing outside a release, so the + # builds can need it: a skipped job would skip every job after them. release-version: name: Check the release version - # Job conditions cannot read workflow env, so this repeats RELEASE. - if: github.event_name == 'workflow_dispatch' && inputs.release runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: version: ${{ steps.check.outputs.version }} steps: - name: Release from main only + if: env.RELEASE == 'true' run: | if [ "$GITHUB_REF" != refs/heads/main ]; then echo "::error::A release is built from main, not $GITHUB_REF." @@ -70,19 +77,24 @@ jobs: fi - uses: actions/checkout@v4 + if: env.RELEASE == 'true' - uses: oven-sh/setup-bun@v2 + if: env.RELEASE == 'true' with: bun-version: 1.4.4 - id: check name: Check the version + if: env.RELEASE == 'true' env: GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} run: bun scripts/scient/release-version.ts build: name: Build and check (${{ matrix.id }}) + needs: release-version runs-on: ${{ matrix.runner }} timeout-minutes: 150 strategy: @@ -109,6 +121,13 @@ jobs: - name: Stamp the build id run: bun scripts/scient/stamp-build-id.ts + # The release's version goes into this checkout only; main is never bumped. + - name: Stamp the release version + if: env.RELEASE == 'true' + env: + VERSION: ${{ needs.release-version.outputs.version }} + run: bun scripts/scient/release-version.ts --stamp "$VERSION" + - name: Install build tools run: | case "$RUNNER_OS" in @@ -171,11 +190,13 @@ jobs: echo "BINARY=$binary" >> "$GITHUB_ENV" "$binary" --version < /dev/null - - name: Check the build reports scient.json's version + - name: Check the build reports the release version if: env.RELEASE == 'true' + env: + VERSION: ${{ needs.release-version.outputs.version }} run: | version="$("$BINARY" --version < /dev/null)" - test "$version" = "scient-agent/$(bun -e 'console.log((await Bun.file("scient.json").json()).version)')" + test "$version" = "scient-agent/$VERSION" - name: Check state isolation run: bun scripts/scient/isolation-check.ts "$BINARY" @@ -367,7 +388,7 @@ jobs: # Runs the exact signed bytes the release publishes on their own platform. verify-macos: name: Check signed build (${{ matrix.id }}) - needs: sign-macos + needs: [release-version, sign-macos] runs-on: ${{ matrix.runner }} timeout-minutes: 30 strategy: @@ -403,11 +424,13 @@ jobs: fi "$binary" --version < /dev/null - - name: Check the build reports scient.json's version + - name: Check the build reports the release version if: env.RELEASE == 'true' + env: + VERSION: ${{ needs.release-version.outputs.version }} run: | version="$("$BINARY" --version < /dev/null)" - test "$version" = "scient-agent/$(bun -e 'console.log((await Bun.file("scient.json").json()).version)')" + test "$version" = "scient-agent/$VERSION" - name: Check state isolation run: bun scripts/scient/isolation-check.ts "$BINARY" @@ -476,6 +499,7 @@ jobs: - name: Check the version is still next env: GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.release-version.outputs.version }} run: bun scripts/scient/release-version.ts - uses: actions/download-artifact@v4 diff --git a/SCIENT.md b/SCIENT.md index b1566eef6e..fe43f4fbcf 100644 --- a/SCIENT.md +++ b/SCIENT.md @@ -2,7 +2,7 @@ Scient Agent is the native research agent of [Scient](https://github.com/ScientFactory/scient-desktop). It is built from [Oh My Pi](https://github.com/can1357/oh-my-pi) (OMP): it inherits OMP's agent runtime whole and changes what makes it a separate product. A machine can run Scient Agent and a stock `omp` side by side without either reading, changing, or updating the other. -`scient.json` records the product version and the exact upstream release this tree derives from. +`scient.json` records the product version and the exact upstream release this tree derives from. A release run writes the version it releases into its own checkout and never commits it, so on `main` the version is only what a build outside a release reports. ## What Scient changes @@ -93,7 +93,7 @@ The 18.8.4 alignment predates markers; its in-place changes were accepted with i ## Branches, releases and upstream updates -- `main` holds Scient Agent. Changes reach it through pull requests that keep their commits (rebase merge; history stays linear). A release is a published GitHub release of `scient.json`'s version, tagged `v` on the `main` commit it was built from: `v0.1.0`, `v0.1.1`, ... A version is used up only when its release is published, so published versions follow each other with no gaps (see Release). +- `main` holds Scient Agent. Changes reach it through pull requests that keep their commits (rebase merge; history stays linear). A release is a published GitHub release of the version its run was given, tagged `v` on the `main` commit it was built from: `v0.1.0`, `v0.1.1`, ... A version is used up only when its release is published, so published versions follow each other with no gaps (see Release). - Scient's commits come in three kinds: the scripts under `scripts/scient/`, generated commits (the output of one generator and nothing else, subject ending in `(generated)`, trailer `Scient-Generator: identity`, `wording` or `rpc`), and hand-written changes (trailer `Scient-Divergence: ` in a replay). A pull request that changes what a script produces carries the output as its own generated commit. - `upstream` is a fetch-only remote for Oh My Pi. `scient/18.4.8` is the branch the work started on, kept as it was. @@ -113,7 +113,7 @@ Build a candidate branch from the pinned upstream commit. Port the generator scr Since 18.10.4 a replay has one shape: the generators; the generated identity commit; one commit per active ledger entry, in ledger order, each with `Scient-Divergence: `; the generated wording commit; the generated RPC commit. The next intake replays those contract commits. It does not replay the history that produced them, and a change made after an alignment is folded into its contract's commit. `rename-wording.ts` stops when a listed text disappears: inspect the new source and repair the mapping rather than dropping the check. -Update `scient.json` with the exact upstream pin and the chosen Scient product version. Review inherited workflows as well as source: preserve Scient's release authority and do not activate upstream publication, updater, reporting or repository-management automation. Run the checks below and affected upstream tests; list intentional exclusions and failures rather than claiming the entire upstream suite passed. +Update `scient.json` with the exact upstream pin; the product version is chosen by the release run, not by the alignment. Review inherited workflows as well as source: preserve Scient's release authority and do not activate upstream publication, updater, reporting or repository-management automation. Run the checks below and affected upstream tests; list intentional exclusions and failures rather than claiming the entire upstream suite passed. Only once the replay candidate is qualified, import its tree into a **clean, disposable integration worktree** based on the frozen Scient `main`: @@ -932,13 +932,13 @@ The Intel macOS job restores Cargo dependency artifacts keyed by the compiler, R The macOS builds are signed by upstream's `scripts/ci-macos-sign.sh`, unchanged, on a Linux runner (it uses a pinned `rcodesign`): ad hoc on every run, so the signing path is exercised by every pull request. A separate job then runs the signed bytes on their own macOS runner: checksum, `codesign --verify --strict`, `--version` and the isolation check. An unsigned macOS build is uploaded under an `unsigned-` name and can never reach a release. -A release follows Scient Desktop's order: the version is checked first, the tag comes last. +A release follows Scient Desktop's order: the version is the run's input, it is checked first, and the tag comes last. No pull request bumps a version. -1. A pull request sets `scient.json`'s version to the one right after the latest published release (next patch, minor or major), in a commit `release: Scient Agent `. -2. Once it is on `main`, run the workflow manually on `main` with `release` set. Its first job (`scripts/scient/release-version.ts`) refuses a version that does not come right after the latest published release, or that a tag carries already. The run signs the macOS builds with the Developer ID, notarizes them and refuses an ad-hoc signature. Only when every job passed does it check the version again and draft a GitHub release with every binary, its zstd copy (`.zst`) and the SHA-256 of each, targeting the commit it built. +1. Run the workflow manually on `main` with `release` set: `gh workflow run scient.yml --repo ScientFactory/scient-agent --ref main -f release=true`. Leave `version` blank to release the next patch after the latest published release, or give the next minor or major (`-f version=0.2.0`). +2. Its first job (`scripts/scient/release-version.ts`) refuses a version that does not come right after the latest published release, or that a tag carries already, before any build starts. Each build writes the version into its own checkout's `scient.json` (`--stamp`) and checks that the binary reports it. The run signs the macOS builds with the Developer ID, notarizes them and refuses an ad-hoc signature. Only when every job passed does it check the version again and draft a GitHub release with every binary, its zstd copy (`.zst`) and the SHA-256 of each, targeting the commit it built. 3. A person publishes the draft. Publishing creates the `v` tag on that commit; a draft has no tag. -A version whose run failed, or whose draft was not published, is not used up. Fix `main` and run the release again with the same version: its draft replaces the unpublished one. Never move `scient.json` to a later version for a retry. A failed job can also be rerun in the same run. Do not publish a draft while a release run of the same version is running: that run deletes the old draft just before it drafts its own, and GitHub cannot limit a deletion to drafts. A `v` tag pushed by hand on a version that was never published blocks it; the release run says so, and the tag is deleted before running again. +A version whose run failed, or whose draft was not published, is not used up. Fix `main` and run the release again with the same version (blank picks it again): its draft replaces the unpublished one. Never skip to a later version for a retry. A failed job can also be rerun in the same run. Do not publish a draft while a release run of the same version is running: that run deletes the old draft just before it drafts its own, and GitHub cannot limit a deletion to drafts. A `v` tag pushed by hand on a version that was never published blocks it; the release run says so, and the tag is deleted before running again. The release job copies the release files to `downloads.scientfactory.com/scient-agent/v/` (Cloudflare R2; secrets `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` and variable `R2_DOWNLOADS_BUCKET`), overwriting the copy of a draft it replaces. Scient Desktop's catalog takes only published releases. Desktop downloads that copy first and falls back to the GitHub release, and its catalog publishes a version only after every copy matches the digest. Desktop prefers the zstd copy, about 40% of the binary's size, unpacks it and checks the binary's SHA-256; it falls back to the binary itself, and Desktops that predate the copies download only the binary. The `compress` job makes the copies on every run with `zstd -19 --long=27` and checks that each unpacks to its binary: Desktop allows a window of at most 2^27 bytes, and the check unpacks with the same limit. A manual run of the workflow with `developer-id` set signs and notarizes without drafting a release; use it to qualify the signing secrets before a release run. Not built yet: Linux with musl (Alpine). The Windows builds are not code-signed. diff --git a/packages/utils/src/scient/product.ts b/packages/utils/src/scient/product.ts index 324496d03f..7c58c78486 100644 --- a/packages/utils/src/scient/product.ts +++ b/packages/utils/src/scient/product.ts @@ -1,5 +1,6 @@ /** - * Scient Agent's own release, as `scient.json` records it. `VERSION` in + * Scient Agent's own release, as `scient.json` records it (a release run writes + * its version there before building). `VERSION` in * `dirs.ts` stays the version of the inherited Oh My Pi packages: internal * compatibility checks, the native addon's stamp among them, compare against it. */ diff --git a/scripts/scient/release-version.test.ts b/scripts/scient/release-version.test.ts index a5e4064c4e..b1b194a64d 100644 --- a/scripts/scient/release-version.test.ts +++ b/scripts/scient/release-version.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { nextVersions, releaseRefusal } from "./release-version"; +import { nextVersions, releaseRefusal, releaseVersion, stampedManifest } from "./release-version"; describe("release version", () => { it("follows the latest published release by one patch, minor or major step", () => { @@ -28,4 +28,24 @@ describe("release version", () => { expect(releaseRefusal("0.1.0", { latestPublished: null, tagExists: false })).toBeUndefined(); expect(releaseRefusal("0.1.9-beta", { latestPublished: "v0.1.8", tagExists: false })).toContain("not x.y.z"); }); + + it("releases the version it was given, or else the next patch", () => { + expect(releaseVersion("0.2.0", "v0.1.8")).toBe("0.2.0"); + expect(releaseVersion(" v0.2.0 ", "v0.1.8")).toBe("0.2.0"); + expect(releaseVersion("", "v0.1.8")).toBe("0.1.9"); + expect(releaseVersion("0.1.0", null)).toBe("0.1.0"); + // Before the first published release there is no next patch to take. + expect(releaseVersion("", null)).toBeUndefined(); + }); + + it("stamps only the product version, keeping scient.json's layout", () => { + const manifest = `${JSON.stringify( + { name: "scient-agent", version: "0.1.8", upstream: { name: "oh-my-pi", version: "18.18.6", commit: "c" } }, + null, + "\t", + )}\n`; + const stamped = stampedManifest(manifest, "0.1.9"); + expect(JSON.parse(stamped)).toEqual({ ...JSON.parse(manifest), version: "0.1.9" }); + expect(stamped).toBe(manifest.replace('"version": "0.1.8"', '"version": "0.1.9"')); + }); }); diff --git a/scripts/scient/release-version.ts b/scripts/scient/release-version.ts index 3b1af603e0..76c9fbc5a8 100755 --- a/scripts/scient/release-version.ts +++ b/scripts/scient/release-version.ts @@ -1,16 +1,23 @@ #!/usr/bin/env bun /** - * Checks that `scient.json`'s version may be released now. As in Scient - * Desktop's release, a version is used up when its release is published, not - * when a build of it is drafted or fails: the version must come right after the - * latest published release, and no tag may carry it yet (publishing a draft - * creates its tag). An unpublished draft holds no tag, so the next release run - * of the same version replaces it. + * Picks the version a release run builds and checks that it may be released + * now. As in Scient Desktop's release, the version is the run's input, not a + * commit: a run given no version releases the next patch after the latest + * published release. A version is used up when its release is published, not + * when a build of it is drafted or fails: it must come right after the latest + * published release, and no tag may carry it yet (publishing a draft creates + * its tag). An unpublished draft holds no tag, so the next release run of the + * same version replaces it. * - * GITHUB_REPOSITORY=ScientFactory/scient-agent bun scripts/scient/release-version.ts + * GITHUB_REPOSITORY=ScientFactory/scient-agent RELEASE_VERSION=0.2.0 bun scripts/scient/release-version.ts * * Prints the version and writes `version=` to `$GITHUB_OUTPUT` when it * is set; exits 1 with the reason when the version may not be released. + * + * bun scripts/scient/release-version.ts --stamp 0.2.0 + * + * Writes the version into this checkout's `scient.json`, so the executable + * built next reports it. A release run stamps each build; nothing commits it. */ import { appendFileSync } from "node:fs"; import * as path from "node:path"; @@ -26,6 +33,16 @@ export function nextVersions(latest: string): string[] { return [`${major}.${minor}.${patch + 1}`, `${major}.${minor + 1}.0`, `${major + 1}.0.0`]; } +/** + * The version a release run builds: the one it was given, or the next patch after + * `latestPublished` (a tag such as `v0.1.8`). Undefined when neither exists. + */ +export function releaseVersion(requested: string, latestPublished: string | null): string | undefined { + const version = requested.trim().replace(/^v/, ""); + if (version) return version; + return latestPublished === null ? undefined : nextVersions(latestPublished.replace(/^v/, ""))[0]; +} + export interface ReleaseState { /** The latest published release's tag (`v0.1.8`), or null before the first one. */ latestPublished: string | null; @@ -35,7 +52,7 @@ export interface ReleaseState { /** Why `version` may not be released in `state`, or undefined when it may. */ export function releaseRefusal(version: string, state: ReleaseState): string | undefined { - if (!VERSION.test(version)) return `scient.json's version ${version} is not x.y.z.`; + if (!VERSION.test(version)) return `The version ${version} is not x.y.z.`; const latest = state.latestPublished?.replace(/^v/, "") ?? null; const next = latest === null ? null : nextVersions(latest); if (state.tagExists) { @@ -44,12 +61,19 @@ export function releaseRefusal(version: string, state: ReleaseState): string | u if (next?.includes(version)) { return `v${version} is tagged, but no release of it is published. A release run never tags; delete that tag, then run the release again.`; } - return `v${version} is tagged already, and a tagged version is never released again. Set scient.json to the version after the latest published release.`; + return `v${version} is tagged already, and a tagged version is never released again. Release the version after the latest published release.`; } if (next === null || next.includes(version)) return undefined; return `The latest published release is v${latest}, so the next release is ${next.join(", ")} (patch, minor or major), not ${version}.`; } +/** `scient.json`'s text with `version` as the product version, its layout unchanged. */ +export function stampedManifest(manifest: string, version: string): string { + const scient = JSON.parse(manifest) as { version: string }; + scient.version = version; + return `${JSON.stringify(scient, null, "\t")}\n`; +} + /** `gh` output, or null when GitHub answers 404; any other failure throws, so the check fails closed. */ async function ghOrNotFound(args: string[]): Promise { const result = await $`gh ${args}`.nothrow().quiet(); @@ -59,23 +83,43 @@ async function ghOrNotFound(args: string[]): Promise { throw new Error(`gh ${args.join(" ")} failed: ${stderr.trim()}`); } -if (import.meta.main) { +async function stamp(version: string | undefined): Promise { + if (!version || !VERSION.test(version)) { + console.error(`--stamp needs a version (x.y.z), not ${version ?? "nothing"}.`); + process.exit(2); + } + const file = path.resolve(import.meta.dir, "..", "..", "scient.json"); + await Bun.write(file, stampedManifest(await Bun.file(file).text(), version)); + console.log(`scient.json now reports ${version}.`); +} + +async function check(): Promise { const repo = process.env.GITHUB_REPOSITORY; if (!repo) { console.error("GITHUB_REPOSITORY is not set."); process.exit(2); } - const repoRoot = path.resolve(import.meta.dir, "..", ".."); - const { version } = (await Bun.file(path.join(repoRoot, "scient.json")).json()) as { version: string }; const latestPublished = await ghOrNotFound(["api", `repos/${repo}/releases/latest`, "--jq", ".tag_name"]); + const version = releaseVersion(process.env.RELEASE_VERSION ?? "", latestPublished); + if (version === undefined) { + console.log("::error::No release is published yet, so give the run the first version to release."); + process.exitCode = 1; + return; + } const tagExists = (await ghOrNotFound(["api", `repos/${repo}/git/ref/tags/v${version}`, "--jq", ".ref"])) !== null; const refusal = releaseRefusal(version, { latestPublished, tagExists }); if (refusal) { console.log(`::error::${refusal}`); process.exitCode = 1; - } else { - console.log(`Releasing ${version}; the latest published release is ${latestPublished ?? "none"}.`); - const output = process.env.GITHUB_OUTPUT; - if (output) appendFileSync(output, `version=${version}\n`); + return; } + console.log(`Releasing ${version}; the latest published release is ${latestPublished ?? "none"}.`); + const output = process.env.GITHUB_OUTPUT; + if (output) appendFileSync(output, `version=${version}\n`); +} + +if (import.meta.main) { + const [flag, value] = process.argv.slice(2); + if (flag === "--stamp") await stamp(value); + else await check(); }