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(); }