Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 40 additions & 16 deletions .github/workflows/scient.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -50,39 +56,45 @@ 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."
exit 1
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:
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions SCIENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<version>` 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<version>` 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: <ids>` 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.

Expand All @@ -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: <id>`; 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`:

Expand Down Expand Up @@ -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 <version>`.
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 (`<binary>.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 (`<binary>.zst`) and the SHA-256 of each, targeting the commit it built.
3. A person publishes the draft. Publishing creates the `v<version>` 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<version>` 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<version>` 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<version>/` (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.

Expand Down
3 changes: 2 additions & 1 deletion packages/utils/src/scient/product.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down
22 changes: 21 additions & 1 deletion scripts/scient/release-version.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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"'));
});
});
Loading
Loading