From 7db17aff955aaf246801613cf67d497d7c194b3a Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Wed, 30 Sep 2026 02:00:27 -0700 Subject: [PATCH] ci(release): studio writes the CLI update pointer the installed updaters read A cli-pointer job publishes the Studio release's CLI archives as a GitHub release vX.Y.Z (the only shape the 1.x updater accepts), records it immutably in R2, then advances cli/{stable,preview}/latest.json. It reuses the 1.x manifest validators and pointer ordering, only moves forward, and is gated by STUDIO_PUBLISHES_CLI. cli-gate requires CLI_PUBLISH_FROZEN so two lines never write the CLI channels. cli-tag creates the vX.Y.Z tag with the release tag identity in one inline step of a job that runs no repository code. An existing release is trusted on its own checksums, never compared with a rebuild. --- .github/workflows/ci.yml | 2 +- .github/workflows/release-studio.yml | 148 +++++- cmd/signpath-contract/cli_pointer_test.go | 158 ++++++ docs/STUDIO_RELEASE.md | 26 +- .../cli/upgrade_pointer_contract_test.go | 134 +++++ scripts/decide-cli-release-publication.sh | 2 +- scripts/publish-cli-pointer.sh | 179 +++++++ scripts/publish-cli-pointer.test.mjs | 468 ++++++++++++++++++ scripts/testdata/cli-pointer-v2.24.0.json | 44 ++ scripts/validate-cli-release-manifest.sh | 2 +- .../crash-report/src/desktop_release.test.ts | 34 ++ 11 files changed, 1192 insertions(+), 5 deletions(-) create mode 100644 cmd/signpath-contract/cli_pointer_test.go create mode 100644 internal/frontend/cli/upgrade_pointer_contract_test.go create mode 100755 scripts/publish-cli-pointer.sh create mode 100644 scripts/publish-cli-pointer.test.mjs create mode 100644 scripts/testdata/cli-pointer-v2.24.0.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2290cd26fb..77038b3ea5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" - - run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs npm/publish.test.mjs + - run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs npm/publish.test.mjs # The ruleset requires the per-OS check names (test (ubuntu-latest) etc.). # A matrix job skipped at job level reports no per-leg checks at all, so diff --git a/.github/workflows/release-studio.yml b/.github/workflows/release-studio.yml index 35dc445d61..c04ecf5bbc 100644 --- a/.github/workflows/release-studio.yml +++ b/.github/workflows/release-studio.yml @@ -1107,6 +1107,108 @@ jobs: --cache-control "public, max-age=300, stale-if-error=86400" echo "Studio catalog -> $VERSION" + # A CLI channel that moves while 1.x still publishes would have two lines + # writing it, so every CLI job waits for this check. It runs no repository code. + cli-gate: + name: require the 1.x line to be frozen + needs: resolve + if: vars.STUDIO_PUBLISHES_CLI == 'true' + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Require CLI_PUBLISH_FROZEN + env: + CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }} + run: | + if [ "$CLI_PUBLISH_FROZEN" != true ]; then + echo "::error::STUDIO_PUBLISHES_CLI is on but CLI_PUBLISH_FROZEN is not: freeze the 1.x line first, or two lines would write the CLI channels" + exit 1 + fi + + # The v tag the update pointer needs is created here, by the release + # identity the 1.x line also uses, because only that identity may create v* + # tags. This job checks out nothing and runs no repository code; the one step + # that reads RELEASE_TAG_TOKEN is written inline and only runs on the studio + # tag or branch, through the studio-release environment's ref policy. + cli-tag: + name: create the CLI tag + needs: [resolve, publish, cli-gate] + if: vars.STUDIO_PUBLISHES_CLI == 'true' + runs-on: ubuntu-latest + environment: studio-release + permissions: + contents: read + steps: + - name: Create v on the studio commit + env: + TAG_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN }} + RELEASE_TAG_ACTOR: ${{ vars.RELEASE_TAG_ACTOR }} + READ_TOKEN: ${{ github.token }} + CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }} + STUDIO_TAG: ${{ needs.resolve.outputs.tag }} + CLI_VERSION: ${{ needs.resolve.outputs.version }} + APPROVED_SHA: ${{ needs.resolve.outputs.sha }} + run: | + set -euo pipefail + repo=esengine/DeepSeek-Reasonix + stable_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + preview_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$' + tag="v${CLI_VERSION#v}" + if [[ ! "$tag" =~ $stable_re && ! "$tag" =~ $preview_re ]]; then + echo "$tag publishes no CLI pointer; no tag is created" + exit 0 + fi + [ "$CLI_PUBLISH_FROZEN" = true ] || { echo "::error::CLI_PUBLISH_FROZEN must be true"; exit 1; } + [ "$GITHUB_REPOSITORY" = "$repo" ] || { echo "::error::the release tag identity is only used in $repo"; exit 1; } + case "$GITHUB_REF" in + refs/tags/studio-v* | refs/heads/studio) ;; + *) echo "::error::the release tag identity is only used on a studio tag or the studio branch, not $GITHUB_REF"; exit 1 ;; + esac + [[ "$APPROVED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::approved SHA is not a full commit SHA"; exit 1; } + [[ "$STUDIO_TAG" == "studio-$CLI_VERSION" ]] || { echo "::error::studio tag does not match the version"; exit 1; } + [ -n "$TAG_TOKEN" ] || { echo "::error::RELEASE_TAG_TOKEN is not configured; the default Actions token cannot create v* tags"; exit 1; } + [[ "$RELEASE_TAG_ACTOR" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] || { echo "::error::configure RELEASE_TAG_ACTOR with the tag publisher's login"; exit 1; } + read_api() { GH_TOKEN="$READ_TOKEN" gh api "$@"; } + write_api() { GH_TOKEN="$TAG_TOKEN" gh api "$@"; } + actor="$(write_api user)" + jq -e --arg login "$RELEASE_TAG_ACTOR" '.type == "User" and (.login | ascii_downcase) == ($login | ascii_downcase)' <<<"$actor" >/dev/null || + { echo "::error::the release tag credential does not identify RELEASE_TAG_ACTOR"; exit 1; } + [ "$(write_api "repos/$repo" --jq .permissions.push)" = true ] || + { echo "::error::the release tag identity cannot push to $repo"; exit 1; } + [ "$(read_api "repos/$repo/commits/$STUDIO_TAG" --jq .sha)" = "$APPROVED_SHA" ] || + { echo "::error::$STUDIO_TAG does not resolve to $APPROVED_SHA"; exit 1; } + case "$(read_api "repos/$repo/compare/studio...$APPROVED_SHA" --jq .status)" in + behind | identical) ;; + *) echo "::error::$APPROVED_SHA is not on studio history"; exit 1 ;; + esac + # Prints the commit the tag points at; status 1 only when the tag does not exist. + tag_commit() { + local ref kind sha + ref="$(read_api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha' 2>"$RUNNER_TEMP/tag.error")" || { + grep -Eiq '404|Not Found' "$RUNNER_TEMP/tag.error" && return 1 + cat "$RUNNER_TEMP/tag.error" >&2 + exit 1 + } + read -r kind sha <<<"$ref" + [ "$kind" != tag ] || sha="$(read_api "repos/$repo/git/tags/$sha" --jq .object.sha)" + echo "$sha" + } + if existing="$(tag_commit)"; then + [ "$existing" = "$APPROVED_SHA" ] || { echo "::error::$tag already exists on $existing, not $APPROVED_SHA"; exit 1; } + echo "$tag already points at $APPROVED_SHA" + exit 0 + fi + if ! write_api "repos/$repo/git/refs" -f "ref=refs/tags/$tag" -f "sha=$APPROVED_SHA" >/dev/null 2>"$RUNNER_TEMP/create.error"; then + # A concurrent run may have created the same tag in the meantime. + if existing="$(tag_commit)" && [ "$existing" = "$APPROVED_SHA" ]; then + echo "$tag already points at $APPROVED_SHA" + exit 0 + fi + cat "$RUNNER_TEMP/create.error" >&2 + exit 1 + fi + echo "created $tag at $APPROVED_SHA" + # The CLI's install channels, npm's `reasonix` and the Homebrew tap. The 1.x # line owns them until this one takes the CLI over; the repository variable # STUDIO_PUBLISHES_CLI=true hands them here. They run once the release @@ -1115,7 +1217,7 @@ jobs: # candidate cask would become the default install. cli-channels: name: publish the CLI to npm and Homebrew - needs: [resolve, publish] + needs: [resolve, publish, cli-gate, cli-tag] if: vars.STUDIO_PUBLISHES_CLI == 'true' runs-on: ubuntu-latest # id-token lets npm publish --provenance attach a Sigstore attestation; the @@ -1163,3 +1265,47 @@ jobs: fi git -c user.name=reasonix -c user.email=reasonix@deepseek.com commit -m "reasonix ${VERSION#v}" git push origin HEAD:main + + # The 1.x updater accepts only a release tagged vMAJOR.MINOR.PATCH whose assets + # sit under that tag, so the archives go to a v release. Only a + # -preview.N version moves preview; other prereleases publish nothing. + cli-pointer: + name: publish the CLI update pointer + needs: [resolve, publish, cli-gate, cli-tag] + if: vars.STUDIO_PUBLISHES_CLI == 'true' + runs-on: ubuntu-latest + concurrency: + group: studio-cli-pointer-${{ contains(needs.resolve.outputs.version, '-') && 'preview' || 'stable' }} + cancel-in-progress: false + permissions: + contents: write # create the CLI release; cli-tag has already made its tag + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.resolve.outputs.sha }} + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-archives + path: cli-archives + - name: Publish the release, its record and the pointer + env: + GH_TOKEN: ${{ github.token }} + CLI_VERSION: ${{ needs.resolve.outputs.version }} + CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }} + APPROVED_SHA: ${{ needs.resolve.outputs.sha }} + STUDIO_TAG: ${{ needs.resolve.outputs.tag }} + REPOSITORY: ${{ github.repository }} + ARCHIVES_DIR: cli-archives + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} + run: | + if [ "$HAS_R2" != "true" ]; then + echo "::error::R2 credentials are not configured; the CLI pointer was not written" + exit 1 + fi + bash scripts/publish-cli-pointer.sh diff --git a/cmd/signpath-contract/cli_pointer_test.go b/cmd/signpath-contract/cli_pointer_test.go new file mode 100644 index 0000000000..076e9713ed --- /dev/null +++ b/cmd/signpath-contract/cli_pointer_test.go @@ -0,0 +1,158 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestCLIPointerJobIsSwitchedSerializedAndNarrow(t *testing.T) { + root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml") + job := mappingValue(jobsOf(t, root), "cli-pointer") + if job == nil { + t.Fatal("cli-pointer job is missing") + } + if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" { + t.Fatalf("cli-pointer if = %q, want the STUDIO_PUBLISHES_CLI switch", got) + } + needs := mappingValue(job, "needs") + if needs == nil || needs.Kind != yaml.SequenceNode { + t.Fatal("cli-pointer must list its needs") + } + seen := map[string]bool{} + for _, n := range needs.Content { + seen[n.Value] = true + } + if !seen["resolve"] || !seen["publish"] || !seen["cli-gate"] { + t.Fatalf("cli-pointer needs %v, want resolve, publish and cli-gate", seen) + } + concurrency := mappingValue(job, "concurrency") + if concurrency == nil || !strings.HasPrefix(mappingScalar(concurrency, "group"), "studio-cli-pointer-") { + t.Fatal("cli-pointer must take its own per-channel lock, never the 1.x workflow's release-cli- group") + } + if mappingScalar(concurrency, "cancel-in-progress") != "false" { + t.Fatal("cli-pointer must not cancel a run that is mid-write") + } + permissions := mappingValue(job, "permissions") + if permissions == nil || permissions.Kind != yaml.MappingNode || len(permissions.Content) != 2 || + mappingScalar(permissions, "contents") != "write" { + t.Fatal("cli-pointer permissions must be exactly contents: write") + } + if !nodeContains(job, "scripts/publish-cli-pointer.sh") { + t.Fatal("cli-pointer must run scripts/publish-cli-pointer.sh") + } + if !nodeContains(job, "vars.CLI_PUBLISH_FROZEN") { + t.Fatal("cli-pointer must pass vars.CLI_PUBLISH_FROZEN to its script") + } + if name, found := refersTo(job, append([]string{"RELEASE_TAG_TOKEN"}, cliChannelSecrets...)); found { + t.Fatalf("cli-pointer reads %s; registry credentials belong to cli-channels only", name) + } +} + +func TestCLIGateBlocksEveryCLIChannel(t *testing.T) { + root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml") + jobs := jobsOf(t, root) + gate := mappingValue(jobs, "cli-gate") + if gate == nil { + t.Fatal("cli-gate job is missing") + } + if got := mappingScalar(gate, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" { + t.Fatalf("cli-gate if = %q, want the STUDIO_PUBLISHES_CLI switch", got) + } + if !nodeContains(gate, "vars.CLI_PUBLISH_FROZEN") { + t.Fatal("cli-gate must read vars.CLI_PUBLISH_FROZEN") + } + if nodeContains(gate, "actions/checkout") || nodeContains(gate, "secrets.") { + t.Fatal("cli-gate must run no repository code and read no secret") + } + for _, name := range []string{"cli-tag", "cli-channels", "cli-pointer"} { + needs := mappingValue(mappingValue(jobs, name), "needs") + found := false + for _, n := range needs.Content { + found = found || n.Value == "cli-gate" + } + if !found { + t.Fatalf("%s does not wait for cli-gate", name) + } + } + for _, name := range []string{"cli-channels", "cli-pointer"} { + found := false + for _, n := range mappingValue(mappingValue(jobs, name), "needs").Content { + found = found || n.Value == "cli-tag" + } + if !found { + t.Fatalf("%s does not wait for cli-tag", name) + } + } +} + +var releaseTagSecrets = []string{"RELEASE_TAG_TOKEN"} + +func workflowFiles(t *testing.T) []string { + t.Helper() + var files []string + for _, pattern := range []string{"*.yml", "*.yaml"} { + found, err := filepath.Glob("../../.github/workflows/" + pattern) + if err != nil { + t.Fatal(err) + } + files = append(files, found...) + } + if len(files) == 0 { + t.Fatal("no workflows found") + } + return files +} + +// The token may appear in one inline step of Studio's cli-tag job and in no +// other env scope: not the workflow's, not a job's, not another step's. +func TestReleaseTagTokenIsReadByOneInlineStep(t *testing.T) { + holders := 0 + for _, file := range workflowFiles(t) { + root := parseWorkflowFile(t, file) + studio := filepath.Base(file) == "release-studio.yml" + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value == "jobs" { + continue + } + if what, found := refersTo(root.Content[i+1], releaseTagSecrets); found { + t.Errorf("%s reaches %s at workflow level (%s)", file, what, root.Content[i].Value) + } + } + jobs := mappingValue(root, "jobs") + if jobs == nil { + continue + } + for i := 0; i+1 < len(jobs.Content); i += 2 { + id, job := jobs.Content[i].Value, jobs.Content[i+1] + allowed := studio && id == "cli-tag" + for j := 0; j+1 < len(job.Content); j += 2 { + key, value := job.Content[j].Value, job.Content[j+1] + if key != "steps" { + if what, found := refersTo(value, releaseTagSecrets); found { + t.Errorf("%s job %s reaches %s in %s; only a step of cli-tag may", file, id, what, key) + } + continue + } + for _, step := range value.Content { + if what, found := refersTo(step, releaseTagSecrets); found { + holders++ + if !allowed { + t.Errorf("%s job %s reaches %s; only Studio's cli-tag step may", file, id, what) + } + if mappingScalar(step, "uses") != "" { + t.Errorf("%s job %s: the token step must be inline shell, not an action", file, id) + } + } else if allowed && mappingScalar(step, "uses") != "" { + t.Errorf("cli-tag must check out nothing and run no action") + } + } + } + } + } + if holders != 1 { + t.Fatalf("%d steps read RELEASE_TAG_TOKEN, want exactly one", holders) + } +} diff --git a/docs/STUDIO_RELEASE.md b/docs/STUDIO_RELEASE.md index a5fd7c6980..e7b0cf3463 100644 --- a/docs/STUDIO_RELEASE.md +++ b/docs/STUDIO_RELEASE.md @@ -60,7 +60,10 @@ Jobs in the run: | `windows-sign-installer` | Requires both packages to hash to the checked values, signs the installer, verifies its signature, and outputs the signed installer's SHA-256. Opens no archive. | shared concurrency group `certum-signing`, environment `studio-release` | | `cli` | Builds `reasonix` archives for six OS/arch targets plus `SHA256SUMS`. | fails on a missing archive | | `publish` | Renders the notes with their authors, minisigns, writes `latest.json`, creates the GitHub prerelease, mirrors to R2. | environment `studio-release`; skipped unless all four Windows signing jobs succeeded or signing is off; an unresolved `#N` stops it before signing | -| `cli-channels` | Only with `STUDIO_PUBLISHES_CLI=true` (unset today; 1.x owns the channels). Publishes `reasonix` and `@reasonix/cli-*` to npm with `--provenance`, then updates the Homebrew cask (not for a candidate). | no environment and no approval; `id-token: write` on this job only; runs after `publish` | +| `cli-gate` | Only with `STUDIO_PUBLISHES_CLI=true`. Requires `CLI_PUBLISH_FROZEN=true`. Checks out nothing and reads no secret. | fails while 1.x is not frozen, and then no other CLI job runs | +| `cli-tag` | Only with `STUDIO_PUBLISHES_CLI=true`. For a stable or `-preview.N` version, creates the tag `vX.Y.Z` on the studio commit with the release tag identity (`RELEASE_TAG_TOKEN`, see below); an existing tag on that commit is kept, one elsewhere fails. | environment `studio-release`; runs after `publish` and `cli-gate`; checks out nothing, one inline step reads the token | +| `cli-channels` | Only with `STUDIO_PUBLISHES_CLI=true` (unset today; 1.x owns the channels). Publishes `reasonix` and `@reasonix/cli-*` to npm with `--provenance`, then updates the Homebrew cask (not for a candidate). | no environment and no approval; `id-token: write` on this job only; runs after `publish`, `cli-gate` and `cli-tag` | +| `cli-pointer` | Only with `STUDIO_PUBLISHES_CLI=true`. Creates the GitHub release `vX.Y.Z` holding the CLI archives, writes `cli/releases/vX.Y.Z/latest.json`, then moves `cli/stable/latest.json` (a `-preview.N` version moves `cli/preview/latest.json`; any other prerelease publishes nothing). | runs after `publish`, `cli-gate` and `cli-tag`; serialized per channel by the `studio-cli-pointer-` lock | The `studio-release` environment allows the `studio-v*` tag and the `studio` branch. It has no required reviewer. @@ -70,6 +73,25 @@ Publishing to the CLI channels needs no human approval. Three controls stand in - The tag protection rule `Protect release tags` covers `studio-v*`, so only someone with write access can push the tag that starts a release. - `npm publish --provenance` attaches a Sigstore attestation naming this repository and commit. +Recovery of the CLI pointer: + +- `cli-pointer` trusts an existing release `vX.Y.Z` after checking it on its own terms: tag, asset names, sizes, URLs, and `SHA256SUMS` against the release's own digests. +- It never compares the release with the rerun's archives, which are not byte-reproducible. +- If the release was created and the R2 write failed, a `workflow_dispatch` run finishes the job. +- The pointer script refuses to run unless `CLI_PUBLISH_FROZEN=true`, and re-reads the pointer after writing it. + +The tag identity: + +- Only a person may create `v*` tags (release ruleset), so `cli-tag` uses the secret `RELEASE_TAG_TOKEN` and variable `RELEASE_TAG_ACTOR` that the 1.x release workflow also uses. This workflow is a second user. +- One inline step reads the token, in a job that checks out and runs no repository code. It creates only the tag, through the refs API; `cli-pointer` creates the release with the run's own token. +- It refuses unless the repository is the official one, the ref is a `studio-v*` tag or `studio`, `CLI_PUBLISH_FROZEN` is true, and the credential belongs to `RELEASE_TAG_ACTOR` with push access. +- It also refuses unless the studio tag still resolves to the approved SHA and that SHA is on `studio` history. +- An existing tag on the same commit (lightweight or annotated) counts as done; on another commit it fails. +- The `studio-release` environment restricts where the job runs. It does not protect the secret: `RELEASE_TAG_TOKEN` is repository-level, so anyone who can push a workflow can read it. The owner is the only writer today. +- The 1.x identity script is not reused: it pins `main-v2` and would need a checkout. The server enforces the rulesets at the tag write. + +Once a GitHub release `vX.Y.Z` exists, the v1.39.5 client's GitHub-list fallback and the gateway's CLI fallback select it, so turning the switch on is the switch of the update channel itself. + While the variable is `true`, pushing a `studio-v*` tag publishes to npm `latest` and to Homebrew. The `workflow_dispatch` recovery run is subject to the same switch. The job reads the repository secrets `NPM_TOKEN` and `HOMEBREW_TAP_TOKEN`, the same pair the 1.x line uses. On the day of the switch, freeze 1.x in the same step by setting its variable `CLI_PUBLISH_FROZEN`, so the two lines never write the channels at once. @@ -241,4 +263,6 @@ Studio and 1.x signing jobs and smoke tests share the concurrency group `certum- | --- | --- | --- | | `studio/versions.json` | this workflow | Studio catalog, newest first | | `studio-vX.Y.Z/` | this workflow | artifacts, signatures, `latest.json` | +| `cli/stable/latest.json`, `cli/preview/latest.json` | `cli-pointer` | what `reasonix upgrade` reads through `crash.reasonix.io/v1/cli/releases//latest.json`; only ever moves to a newer version | +| `cli/releases/vX.Y.Z/latest.json` | `cli-pointer` | immutable record of one CLI release; a rerun with different content fails | | `versions.json` | desktop line | never written by this workflow | diff --git a/internal/frontend/cli/upgrade_pointer_contract_test.go b/internal/frontend/cli/upgrade_pointer_contract_test.go new file mode 100644 index 0000000000..ec0d11e650 --- /dev/null +++ b/internal/frontend/cli/upgrade_pointer_contract_test.go @@ -0,0 +1,134 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "golang.org/x/mod/semver" +) + +// The acceptance rules of the updater shipped in v1.39.5, kept as they were +// there: installed binaries cannot change, so a pointer must satisfy these +// whatever the current code accepts. +var legacy139StableTag = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$`) + +func legacy139IsCLITag(tag string) bool { + tag = strings.TrimSpace(tag) + return len(tag) >= 2 && tag[0] == 'v' && tag[1] >= '0' && tag[1] <= '9' +} + +func legacy139ExpectedAssetURL(raw, tag, name string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil { + return false + } + want := fmt.Sprintf("/%s/%s/releases/download/%s/%s", ghOwner, ghRepo, tag, name) + return strings.EqualFold(parsed.Hostname(), "github.com") && + parsed.Port() == "" && + parsed.EscapedPath() == want && + parsed.RawQuery == "" && + parsed.Fragment == "" +} + +func legacy139Accepts(rel ghRelease) error { + if !legacy139IsCLITag(rel.TagName) || !legacy139StableTag.MatchString(rel.TagName) || rel.Prerelease { + return fmt.Errorf("tag %q prerelease=%v is not a stable CLI release", rel.TagName, rel.Prerelease) + } + for _, name := range requiredCLIAssets { + found := false + for _, asset := range rel.Assets { + if asset.Name != name { + continue + } + if found { + return fmt.Errorf("duplicate asset %s", name) + } + found = true + if asset.Size <= 0 || asset.Size > 1<<30 || !legacy139ExpectedAssetURL(asset.BrowserDownloadURL, rel.TagName, name) { + return fmt.Errorf("asset %s: size %d url %s", name, asset.Size, asset.BrowserDownloadURL) + } + } + if !found { + return fmt.Errorf("missing asset %s", name) + } + } + return nil +} + +func loadStudioPointer(t *testing.T) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "..", "scripts", "testdata", "cli-pointer-v2.24.0.json")) + if err != nil { + t.Fatal(err) + } + return data +} + +func TestStudioPointerSatisfiesTheV1395UpdaterRules(t *testing.T) { + var rel ghRelease + if err := json.Unmarshal(loadStudioPointer(t), &rel); err != nil { + t.Fatal(err) + } + if err := legacy139Accepts(rel); err != nil { + t.Fatalf("v1.39.5 would refuse the pointer: %v", err) + } + if semver.Compare(rel.TagName, "v1.39.5") <= 0 { + t.Fatalf("%s does not sort above the 1.x line", rel.TagName) + } +} + +func TestStudioTagShapesTheV1395UpdaterRefuses(t *testing.T) { + var rel ghRelease + if err := json.Unmarshal(loadStudioPointer(t), &rel); err != nil { + t.Fatal(err) + } + rewrite := func(tag string) ghRelease { + out := rel + out.TagName = tag + out.Assets = append([]ghAsset(nil), rel.Assets...) + for i := range out.Assets { + out.Assets[i].BrowserDownloadURL = strings.ReplaceAll(rel.Assets[i].BrowserDownloadURL, "/"+rel.TagName+"/", "/"+tag+"/") + } + return out + } + for _, tag := range []string{"studio-v2.24.0", "v2.24.0-rc.1", "v2.24.0-preview.1"} { + if legacy139Accepts(rewrite(tag)) == nil { + t.Errorf("v1.39.5 accepts %q as the stable pointer", tag) + } + } + mismatched := rel + mismatched.Assets = append([]ghAsset(nil), rel.Assets...) + mismatched.Assets[0].BrowserDownloadURL = strings.Replace(rel.Assets[0].BrowserDownloadURL, "/v2.24.0/", "/studio-v2.24.0/", 1) + if legacy139Accepts(mismatched) == nil { + t.Error("v1.39.5 accepts an asset URL under a tag other than the pointer's") + } +} + +func TestCurrentUpdaterReadsTheStudioPointer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(loadStudioPointer(t)) + })) + defer server.Close() + rel, err := fetchCLIReleasePointer(server.Client(), server.URL, cliReleaseStable) + if err != nil { + t.Fatal(err) + } + for _, target := range [][2]string{ + {"darwin", "amd64"}, {"darwin", "arm64"}, {"linux", "amd64"}, {"linux", "arm64"}, {"windows", "amd64"}, {"windows", "arm64"}, + } { + if findCLIPlatformAsset(rel, target[0], target[1]) == nil { + t.Errorf("no asset for %s/%s", target[0], target[1]) + } + } + if findCLIReleaseAsset(rel, "SHA256SUMS") == nil { + t.Error("no SHA256SUMS") + } +} diff --git a/scripts/decide-cli-release-publication.sh b/scripts/decide-cli-release-publication.sh index aa854d4991..f5b45ebecf 100755 --- a/scripts/decide-cli-release-publication.sh +++ b/scripts/decide-cli-release-publication.sh @@ -9,7 +9,7 @@ checksums="${5:-}" stable_tag_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' preview_tag_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$' -release_tag_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?)?$' +release_tag_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$' case "$channel" in stable) diff --git a/scripts/publish-cli-pointer.sh b/scripts/publish-cli-pointer.sh new file mode 100755 index 0000000000..3418c2dab3 --- /dev/null +++ b/scripts/publish-cli-pointer.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# Publish one Studio release's CLI archives as a CLI release the installed +# updaters accept, then advance the public pointer for its channel. +# +# The updater shipped in 1.x reads crash.reasonix.io/v1/cli/releases/stable/latest.json +# and refuses any release whose tag is not vMAJOR.MINOR.PATCH or whose asset URLs are +# not github.com//releases/download//. The archives +# therefore live in a GitHub release named v beside the studio-v +# one, and the manifest is built from that release exactly as the 1.x line builds it. +# +# Order matters: GitHub release, then the immutable per-tag record, then the +# mutable pointer. A rerun that finds any of them already there checks it against +# its own contents and keeps it; archives are not byte-reproducible, so an +# existing release is never compared with a rebuild. +# +# The 1.x line writes the same pointer under a different lock, so this only runs +# while it is frozen. +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +version="${CLI_VERSION:?CLI_VERSION is required (for example v2.24.0)}" +repository="${REPOSITORY:?REPOSITORY is required}" +archives="${ARCHIVES_DIR:?ARCHIVES_DIR is required}" +bucket="${R2_BUCKET:?R2_BUCKET is required}" +studio_tag="${STUDIO_TAG:?STUDIO_TAG is required}" +approved_sha="${APPROVED_SHA:?APPROVED_SHA is required}" +[ "${CLI_PUBLISH_FROZEN:-}" = true ] || { + echo "::error::CLI_PUBLISH_FROZEN must be true: the 1.x line still owns the CLI update pointer" + exit 1 +} +endpoint="${R2_ENDPOINT:-https://${R2_ACCOUNT_ID:?R2_ACCOUNT_ID or R2_ENDPOINT is required}.r2.cloudflarestorage.com}" + +tag="v${version#v}" +stable_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +preview_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$' +if [[ "$tag" =~ $stable_re ]]; then + channel=stable + prerelease=false +elif [[ "$tag" =~ $preview_re ]]; then + channel=preview + prerelease=true +else + echo "::notice::$tag is neither a stable nor a -preview.N version; no CLI release or pointer is published for it" + exit 0 +fi + +# The cli-tag job creates the v* tag before this runs; it has to be +# there already, on the commit the studio release was built from. +RELEASE_TAG="$tag" APPROVED_SHA="$approved_sha" VERIFY_RELEASE_CHECKOUT=false \ + bash "$script_dir/verify-release-tag.sh" + +assets=( + reasonix-darwin-amd64.tar.gz + reasonix-darwin-arm64.tar.gz + reasonix-linux-amd64.tar.gz + reasonix-linux-arm64.tar.gz + reasonix-windows-amd64.zip + reasonix-windows-arm64.zip + SHA256SUMS +) + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +not_found='404|NoSuchKey|Not Found' + +digest_of() { shasum -a 256 "$1" | awk '{print $1}'; } + +# The digest list has to describe the very bytes about to be uploaded. +for name in "${assets[@]}"; do + [ -f "$archives/$name" ] || { echo "::error::no $archives/$name"; exit 1; } +done +[ "$(grep -c '' "$archives/SHA256SUMS")" -eq 6 ] || { + echo "::error::SHA256SUMS must list exactly the six archives" + exit 1 +} +for name in "${assets[@]}"; do + [ "$name" = SHA256SUMS ] && continue + grep -Fxq "$(digest_of "$archives/$name") $name" "$archives/SHA256SUMS" || { + echo "::error::SHA256SUMS does not match $name" + exit 1 + } +done + +release_json="$work/release.json" +release_error="$work/release.error" +checksums="$work/SHA256SUMS" +if gh api "repos/$repository/releases/tags/$tag" >"$release_json" 2>"$release_error"; then + gh release download "$tag" -R "$repository" --pattern SHA256SUMS --output "$checksums" + decision="$(bash "$script_dir/decide-cli-release-publication.sh" "$channel" "$tag" "$repository" "$release_json" "$checksums")" +elif grep -Eiq "$not_found" "$release_error"; then + decision=publish +else + cat "$release_error" >&2 + exit 1 +fi + +if [ "$decision" = publish ]; then + flags=(--latest=false) + [ "$prerelease" = true ] && flags+=(--prerelease) + # --verify-tag: a missing tag is an error here, never something to create. + files=() + for name in "${assets[@]}"; do files+=("$archives/$name"); done + gh release create "$tag" "${files[@]}" -R "$repository" --verify-tag "${flags[@]}" \ + --title "Reasonix CLI $tag" \ + --notes "CLI archives built from the Studio release [$studio_tag](https://github.com/$repository/releases/tag/$studio_tag)." + gh api "repos/$repository/releases/tags/$tag" >"$release_json" + gh release download "$tag" -R "$repository" --pattern SHA256SUMS --output "$checksums" + [ "$(bash "$script_dir/decide-cli-release-publication.sh" "$channel" "$tag" "$repository" "$release_json" "$checksums")" = reuse ] || { + echo "::error::the CLI release $tag was created but does not verify" + exit 1 + } +fi + +manifest="$work/manifest.json" +jq --arg tag "$tag" --argjson names "$(printf '%s\n' "${assets[@]}" | jq -R . | jq -sc .)" ' + . as $release | + ($release.assets | map({key: .name, value: .}) | from_entries) as $by_name | + { + tag_name: $release.tag_name, + prerelease: $release.prerelease, + html_url: $release.html_url, + release_notes_url: null, + assets: [$names[] as $name | $by_name[$name] | {name, browser_download_url, size}] + } +' "$release_json" >"$manifest" +bash "$script_dir/validate-cli-release-manifest.sh" "legacy-$channel" "$tag" "$repository" "$manifest" "$tag" + +json_type=(--content-type "application/json; charset=utf-8") +immutable_key="cli/releases/$tag/latest.json" +immutable="$work/immutable.json" +if aws s3 cp "s3://$bucket/$immutable_key" "$immutable" --endpoint-url "$endpoint" 2>"$work/immutable.error"; then + bash "$script_dir/validate-cli-release-manifest.sh" "legacy-$channel" "$tag" "$repository" "$immutable" "$tag" + bash "$script_dir/compare-cli-release-manifests.sh" "$manifest" "$immutable" || { + echo "::error::immutable CLI release metadata for $tag already exists with different content" + exit 1 + } + echo "immutable CLI release metadata for $tag already exists; preserving it" +elif grep -Eiq "$not_found" "$work/immutable.error"; then + aws s3 cp "$manifest" "s3://$bucket/$immutable_key" --endpoint-url "$endpoint" "${json_type[@]}" \ + --cache-control "public, max-age=31536000, immutable" +else + cat "$work/immutable.error" >&2 + exit 1 +fi +aws s3 cp "s3://$bucket/$immutable_key" "$immutable" --endpoint-url "$endpoint" +bash "$script_dir/validate-cli-release-manifest.sh" "legacy-$channel" "$tag" "$repository" "$immutable" "$tag" +bash "$script_dir/compare-cli-release-manifests.sh" "$manifest" "$immutable" + +pointer_key="cli/$channel/latest.json" +pointer="$work/pointer.json" +current=- +if aws s3 cp "s3://$bucket/$pointer_key" "$pointer" --endpoint-url "$endpoint" 2>"$work/pointer.error"; then + current_tag="$(jq -er '.tag_name | strings' "$pointer")" + bash "$script_dir/validate-cli-release-manifest.sh" "legacy-$channel" "$current_tag" "$repository" "$pointer" "$current_tag" + current="$pointer" +elif ! grep -Eiq "$not_found" "$work/pointer.error"; then + cat "$work/pointer.error" >&2 + exit 1 +fi + +if [ "$(bash "$script_dir/decide-cli-pointer-update.sh" "$channel" "$manifest" "$current")" = skip ]; then + echo "CLI $channel pointer stays; $tag is not newer and needs no repair" + exit 0 +fi +aws s3 cp "$manifest" "s3://$bucket/$pointer_key" --endpoint-url "$endpoint" "${json_type[@]}" \ + --cache-control "public, max-age=300, stale-if-error=86400" +aws s3 cp "s3://$bucket/$pointer_key" "$pointer" --endpoint-url "$endpoint" +bash "$script_dir/validate-cli-release-manifest.sh" "legacy-$channel" "$tag" "$repository" "$pointer" "$tag" +if ! cmp -s "$manifest" "$pointer"; then + # Another writer got in between our write and this read; only a newer pointer is acceptable. + if [ "$(bash "$script_dir/decide-cli-pointer-update.sh" "$channel" "$manifest" "$pointer")" != skip ]; then + echo "::error::the CLI $channel pointer was replaced by an older or different manifest after this run wrote $tag" + exit 1 + fi + echo "::warning::the CLI $channel pointer moved past $tag while this run was writing it" + exit 0 +fi +echo "CLI $channel pointer -> $tag" diff --git a/scripts/publish-cli-pointer.test.mjs b/scripts/publish-cli-pointer.test.mjs new file mode 100644 index 0000000000..f142aab140 --- /dev/null +++ b/scripts/publish-cli-pointer.test.mjs @@ -0,0 +1,468 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const script = path.join(here, "publish-cli-pointer.sh"); +const repo = "esengine/DeepSeek-Reasonix"; +const bucket = "bkt"; + +const names = [ + "reasonix-darwin-amd64.tar.gz", + "reasonix-darwin-arm64.tar.gz", + "reasonix-linux-amd64.tar.gz", + "reasonix-linux-arm64.tar.gz", + "reasonix-windows-amd64.zip", + "reasonix-windows-arm64.zip", +]; + +// A stand-in for the two CLIs the script drives. gh keeps releases in +// gh.json, aws keeps objects under r2//; both append to calls.log. +const ghStub = `#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const dir = process.env.STUB_DIR; +const args = process.argv.slice(2); +fs.appendFileSync(path.join(dir, "calls.log"), "gh " + args.join(" ") + "\\n"); +const db = path.join(dir, "gh.json"); +const releases = fs.existsSync(db) ? JSON.parse(fs.readFileSync(db, "utf8")) : {}; +const repo = ${JSON.stringify(repo)}; +if (args[0] === "api") { + const tag = args[1].split("/releases/tags/")[1]; + if (!releases[tag]) { console.error("gh: Not Found (HTTP 404)"); process.exit(1); } + console.log(JSON.stringify(releases[tag].json)); +} else if (args[0] === "release" && args[1] === "download") { + const tag = args[2]; + fs.writeFileSync(args[args.indexOf("--output") + 1], releases[tag].sums); +} else if (args[0] === "release" && args[1] === "create") { + if (process.env.STUB_GH_FAIL) { console.error("boom"); process.exit(1); } + const tag = args[2]; + const files = args.slice(3).filter((a) => !a.startsWith("-") && fs.existsSync(a) && fs.statSync(a).isFile()); + const flags = args.filter((a) => a.startsWith("--")); + releases[tag] = { + sums: fs.readFileSync(files.find((f) => path.basename(f) === "SHA256SUMS"), "utf8"), + flags, + json: { + tag_name: tag, + draft: false, + prerelease: flags.includes("--prerelease"), + html_url: "https://github.com/" + repo + "/releases/tag/" + tag, + assets: files.map((f) => ({ + name: path.basename(f), + size: fs.statSync(f).size, + state: "uploaded", + digest: "sha256:" + crypto.createHash("sha256").update(fs.readFileSync(f)).digest("hex"), + browser_download_url: "https://github.com/" + repo + "/releases/download/" + tag + "/" + path.basename(f), + })), + }, + }; + fs.writeFileSync(db, JSON.stringify(releases)); +} else { console.error("unexpected gh call"); process.exit(2); } +`; + +const awsStub = `#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const dir = process.env.STUB_DIR; +const args = process.argv.slice(2); +const [src, dst] = [args[2], args[3]]; +const key = (u) => path.join(dir, "r2", u.slice("s3://".length)); +if (src.startsWith("s3://")) { + fs.appendFileSync(path.join(dir, "calls.log"), "aws get " + src + "\\n"); + if (!fs.existsSync(key(src))) { console.error("An error occurred (404) when calling the HeadObject operation: Key does not exist"); process.exit(1); } + fs.copyFileSync(key(src), dst); +} else { + fs.appendFileSync(path.join(dir, "calls.log"), "aws put " + dst + "\\n"); + if (process.env.STUB_R2_FAIL_PUT) { console.error("denied"); process.exit(1); } + fs.mkdirSync(path.dirname(key(dst)), { recursive: true }); + fs.copyFileSync(src, key(dst)); +} +`; + +function world() { + const dir = mkdtempSync(path.join(tmpdir(), "cli-pointer-")); + const bin = path.join(dir, "bin"); + mkdirSync(bin); + for (const [name, body] of [["gh", ghStub], ["aws", awsStub]]) { + writeFileSync(path.join(bin, name), body); + chmodSync(path.join(bin, name), 0o755); + } + const archives = path.join(dir, "archives"); + mkdirSync(archives); + const sums = []; + for (const name of names) { + const body = Buffer.from(`archive:${name}`); + writeFileSync(path.join(archives, name), body); + sums.push(`${createHash("sha256").update(body).digest("hex")} ${name}`); + } + writeFileSync(path.join(archives, "SHA256SUMS"), sums.join("\n") + "\n"); + const origin = path.join(dir, "origin.git"); + const work = path.join(dir, "work"); + const git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + mkdirSync(origin); + git(origin, "init", "-q", "--bare"); + mkdirSync(work); + git(work, "init", "-q"); + git(work, "remote", "add", "origin", origin); + git(work, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "studio"); + const sha = git(work, "rev-parse", "HEAD"); + return { dir, bin, archives, work, sha, git: (...args) => git(work, ...args) }; +} + +function run(w, version, extra = {}) { + const { tagged = true, ...env } = extra; + if (tagged && !w.git("ls-remote", "--tags", "origin", `refs/tags/v${version.replace(/^v/, "")}`)) { + w.git("tag", `v${version.replace(/^v/, "")}`, w.sha); + w.git("push", "-q", "origin", `v${version.replace(/^v/, "")}`); + } + return spawnSync("bash", [script], { + encoding: "utf8", + cwd: w.work, + env: { + ...process.env, + PATH: `${w.bin}:${process.env.PATH}`, + STUB_DIR: w.dir, + CLI_VERSION: version, + STUDIO_TAG: `studio-${version}`, + REPOSITORY: repo, + ARCHIVES_DIR: w.archives, + R2_BUCKET: bucket, + R2_ENDPOINT: "https://r2.invalid", + APPROVED_SHA: w.sha, + CLI_PUBLISH_FROZEN: "true", + CLI_TAG_WAIT_ATTEMPTS: "1", + ...env, + }, + }); +} + +const r2 = (w, key) => path.join(w.dir, "r2", bucket, key); +const calls = (w) => { + const log = path.join(w.dir, "calls.log"); + return existsSync(log) ? readFileSync(log, "utf8").split("\n").filter(Boolean) : []; +}; +const puts = (w) => calls(w).filter((c) => c.startsWith("aws put ")).map((c) => c.slice("aws put s3://bkt/".length)); +const created = (w) => calls(w).filter((c) => c.startsWith("gh release create")); + +const legacyPointer = (tag) => ({ + tag_name: tag, + prerelease: false, + html_url: `https://github.com/${repo}/releases/tag/${tag}`, + release_notes_url: null, + assets: [...names, "SHA256SUMS"].map((name) => ({ + name, + browser_download_url: `https://github.com/${repo}/releases/download/${tag}/${name}`, + size: 42, + })), +}); + +function seed(w, key, value) { + mkdirSync(path.dirname(r2(w, key)), { recursive: true }); + writeFileSync(r2(w, key), JSON.stringify(value, null, 2) + "\n"); +} + +test("a stable version publishes the release, then the immutable record, then the pointer", () => { + const w = world(); + seed(w, "cli/stable/latest.json", legacyPointer("v1.39.5")); + const result = run(w, "v2.24.0"); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(puts(w), ["cli/releases/v2.24.0/latest.json", "cli/stable/latest.json"]); + const order = calls(w).map((c) => (c.startsWith("gh release create") ? "release" : c.startsWith("aws put") ? "put" : "")); + assert.equal(order.indexOf("release") < order.indexOf("put"), true); + assert.match(created(w)[0], /--latest=false/); + assert.doesNotMatch(created(w)[0], /--prerelease/); + assert.equal(readFileSync(r2(w, "cli/stable/latest.json"), "utf8"), readFileSync(r2(w, "cli/releases/v2.24.0/latest.json"), "utf8")); +}); + +test("the pointer is what the 1.x updater reads: strict tag, GitHub URLs under that same tag", () => { + const w = world(); + assert.equal(run(w, "v2.24.0").status, 0); + const pointer = JSON.parse(readFileSync(r2(w, "cli/stable/latest.json"), "utf8")); + const golden = JSON.parse(readFileSync(path.join(here, "testdata", "cli-pointer-v2.24.0.json"), "utf8")); + assert.deepEqual(pointer, golden); + assert.deepEqual(Object.keys(pointer), ["tag_name", "prerelease", "html_url", "release_notes_url", "assets"]); + assert.deepEqual(pointer.assets.map((a) => a.name), [...names, "SHA256SUMS"]); + for (const asset of pointer.assets) { + assert.equal(asset.browser_download_url, `https://github.com/${repo}/releases/download/v2.24.0/${asset.name}`); + } +}); + +test("a preview version moves only the preview pointer", () => { + const w = world(); + const stable = legacyPointer("v1.39.5"); + seed(w, "cli/stable/latest.json", stable); + const before = readFileSync(r2(w, "cli/stable/latest.json"), "utf8"); + const result = run(w, "v2.25.0-preview.1"); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(puts(w), ["cli/releases/v2.25.0-preview.1/latest.json", "cli/preview/latest.json"]); + assert.equal(readFileSync(r2(w, "cli/stable/latest.json"), "utf8"), before); + assert.match(created(w)[0], /--prerelease/); +}); + +test("any other prerelease publishes nothing", () => { + for (const version of ["v2.25.0-rc.1", "v2.25.0-beta", "v2.25.0-preview", "v2.25.0-preview.01"]) { + const w = world(); + seed(w, "cli/stable/latest.json", legacyPointer("v1.39.5")); + const result = run(w, version); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(calls(w), [], version); + } +}); + +test("a rerun changes nothing and creates no second release", () => { + const w = world(); + assert.equal(run(w, "v2.24.0").status, 0); + const pointer = readFileSync(r2(w, "cli/stable/latest.json"), "utf8"); + const before = puts(w).length; + const result = run(w, "v2.24.0"); + assert.equal(result.status, 0, result.stderr); + assert.equal(puts(w).length, before); + assert.equal(created(w).length, 1); + assert.equal(readFileSync(r2(w, "cli/stable/latest.json"), "utf8"), pointer); +}); + +test("an older version never moves the pointer back", () => { + const w = world(); + assert.equal(run(w, "v2.30.0").status, 0); + const pointer = readFileSync(r2(w, "cli/stable/latest.json"), "utf8"); + assert.equal(run(w, "v2.24.0").status, 0); + assert.equal(readFileSync(r2(w, "cli/stable/latest.json"), "utf8"), pointer); + assert.equal(existsSync(r2(w, "cli/releases/v2.24.0/latest.json")), true); +}); + +test("an immutable record with different content stops the run before the pointer", () => { + const w = world(); + seed(w, "cli/stable/latest.json", legacyPointer("v1.39.5")); + const other = legacyPointer("v2.24.0"); + other.assets[0].size = 1; + seed(w, "cli/releases/v2.24.0/latest.json", other); + const before = readFileSync(r2(w, "cli/stable/latest.json"), "utf8"); + const result = run(w, "v2.24.0"); + assert.notEqual(result.status, 0); + assert.equal(readFileSync(r2(w, "cli/stable/latest.json"), "utf8"), before); +}); + +test("a failed GitHub release writes nothing to R2", () => { + const w = world(); + const result = run(w, "v2.24.0", { STUB_GH_FAIL: "1" }); + assert.notEqual(result.status, 0); + assert.deepEqual(puts(w), []); +}); + +test("a failed immutable write leaves the pointer alone", () => { + const w = world(); + seed(w, "cli/stable/latest.json", legacyPointer("v1.39.5")); + const before = readFileSync(r2(w, "cli/stable/latest.json"), "utf8"); + const result = run(w, "v2.24.0", { STUB_R2_FAIL_PUT: "1" }); + assert.notEqual(result.status, 0); + assert.equal(readFileSync(r2(w, "cli/stable/latest.json"), "utf8"), before); +}); + +test("archives that disagree with SHA256SUMS are refused before anything is written", () => { + const w = world(); + writeFileSync(path.join(w.archives, names[0]), "tampered"); + const result = run(w, "v2.24.0"); + assert.notEqual(result.status, 0); + assert.deepEqual(calls(w), []); +}); + +test("a missing archive is refused before anything is written", () => { + const w = world(); + writeFileSync(path.join(w.archives, "SHA256SUMS"), ""); + const result = run(w, "v2.24.0"); + assert.notEqual(result.status, 0); + assert.deepEqual(calls(w), []); +}); + +test("a version whose v tag is missing publishes nothing", () => { + const w = world(); + const result = run(w, "v2.24.0", { tagged: false }); + assert.notEqual(result.status, 0); + assert.deepEqual(calls(w), []); +}); + +test("a v tag on another commit publishes nothing", () => { + const w = world(); + const result = run(w, "v2.24.0", { APPROVED_SHA: "0".repeat(40) }); + assert.notEqual(result.status, 0); + assert.deepEqual(calls(w), []); +}); + +test("an existing release is trusted on its own terms, not compared with a rebuild", () => { + const w = world(); + assert.equal(run(w, "v2.24.0").status, 0); + rmSync(r2(w, "cli/releases/v2.24.0/latest.json")); + rmSync(r2(w, "cli/stable/latest.json")); + const sums = []; + for (const name of names) { + const body = Buffer.from(`rebuilt:${name}`); + writeFileSync(path.join(w.archives, name), body); + sums.push(`${createHash("sha256").update(body).digest("hex")} ${name}`); + } + writeFileSync(path.join(w.archives, "SHA256SUMS"), sums.join("\n") + "\n"); + const result = run(w, "v2.24.0"); + assert.equal(result.status, 0, result.stderr); + assert.equal(created(w).length, 1); + const pointer = JSON.parse(readFileSync(r2(w, "cli/stable/latest.json"), "utf8")); + assert.equal(pointer.tag_name, "v2.24.0"); +}); + +test("an existing release whose checksums disagree with its own assets is refused", () => { + const w = world(); + assert.equal(run(w, "v2.24.0").status, 0); + const db = path.join(w.dir, "gh.json"); + const releases = JSON.parse(readFileSync(db, "utf8")); + releases["v2.24.0"].sums = releases["v2.24.0"].sums.replace(/^./, (c) => (c === "0" ? "1" : "0")); + writeFileSync(db, JSON.stringify(releases)); + rmSync(r2(w, "cli/stable/latest.json")); + assert.notEqual(run(w, "v2.24.0").status, 0); + assert.equal(existsSync(r2(w, "cli/stable/latest.json")), false); +}); + +test("the pointer is not written while the 1.x line is not frozen", () => { + const w = world(); + const result = run(w, "v2.24.0", { CLI_PUBLISH_FROZEN: "" }); + assert.notEqual(result.status, 0); + assert.deepEqual(calls(w), []); +}); + +const workflow = path.join(here, "..", ".github", "workflows", "release-studio.yml"); + +function stepScript(stepName) { + const lines = readFileSync(workflow, "utf8").split("\n"); + const at = lines.findIndex((l) => l.trim() === `- name: ${stepName}`); + assert.notEqual(at, -1, `step ${stepName} is missing`); + const run = lines.findIndex((l, i) => i > at && l.trim() === "run: |"); + const indent = lines[run].search(/\S/) + 2; + const body = []; + for (let i = run + 1; i < lines.length; i++) { + if (lines[i].trim() !== "" && lines[i].search(/\S/) < indent) break; + body.push(lines[i].slice(indent)); + } + return body.join("\n"); +} + +const tagGhStub = `#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const dir = process.env.STUB_DIR; +const args = process.argv.slice(2); +const target = args[1]; +fs.appendFileSync(path.join(dir, "api.log"), target + "|" + process.env.GH_TOKEN + "\\n"); +const e = process.env; +if (args[0] !== "api") { console.error("unexpected gh call"); process.exit(2); } +if (target === "user") console.log(JSON.stringify({ type: "User", login: e.STUB_LOGIN || "esengine" })); +else if (target === "repos/esengine/DeepSeek-Reasonix") console.log("true"); +else if (target.includes("/commits/")) console.log(e.STUB_TAG_SHA); +else if (target.includes("/compare/")) console.log(e.STUB_COMPARE || "behind"); +else if (target.includes("/git/ref/tags/")) { + const n = Number(fs.existsSync(path.join(dir, "refcount")) ? fs.readFileSync(path.join(dir, "refcount"), "utf8") : 0); + fs.writeFileSync(path.join(dir, "refcount"), String(n + 1)); + const existing = n === 0 ? e.STUB_EXISTING : e.STUB_EXISTING_AFTER_CREATE_FAIL || e.STUB_EXISTING; + if (existing) console.log((e.STUB_ANNOTATED ? "tag " : "commit ") + (e.STUB_ANNOTATED ? "t".repeat(40) : existing)); + else { console.error("gh: Not Found (HTTP 404)"); process.exit(1); } +} else if (target.includes("/git/tags/")) console.log(e.STUB_EXISTING); +else if (target.endsWith("/git/refs")) { + if (e.STUB_CREATE_FAILS) { console.error("gh: Reference already exists (HTTP 422)"); process.exit(1); } + fs.appendFileSync(path.join(dir, "created.log"), args.slice(2).join(" ") + "\\n"); +} else { console.error("unexpected api " + target); process.exit(2); } +`; + +const sha = "a".repeat(40); +function tagJob(extra = {}) { + const dir = mkdtempSync(path.join(tmpdir(), "cli-tag-")); + const bin = path.join(dir, "bin"); + mkdirSync(bin); + writeFileSync(path.join(bin, "gh"), tagGhStub); + chmodSync(path.join(bin, "gh"), 0o755); + const version = extra.version ?? "v2.24.0"; + const result = spawnSync("bash", ["-c", stepScript("Create v on the studio commit")], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + STUB_DIR: dir, + STUB_TAG_SHA: sha, + RUNNER_TEMP: dir, + TAG_TOKEN: "tag-token", + READ_TOKEN: "read-token", + RELEASE_TAG_ACTOR: "esengine", + CLI_PUBLISH_FROZEN: "true", + CLI_VERSION: version, + STUDIO_TAG: `studio-${version}`, + APPROVED_SHA: sha, + GITHUB_REPOSITORY: repo, + GITHUB_REF: `refs/tags/studio-${version}`, + ...extra.env, + }, + }); + const read = (f) => (existsSync(path.join(dir, f)) ? readFileSync(path.join(dir, f), "utf8") : ""); + return { result, api: read("api.log"), created: read("created.log") }; +} + +test("the tag job creates v on the studio commit and only the identity calls carry the tag token", () => { + const { result, api, created } = tagJob(); + assert.equal(result.status, 0, result.stderr); + assert.match(created, /ref=refs\/tags\/v2\.24\.0 -f sha=a{40}/); + for (const line of api.trim().split("\n")) { + const [target, token] = line.split("|"); + const writes = target === "user" || target === "repos/esengine/DeepSeek-Reasonix" || target.endsWith("/git/refs"); + assert.equal(token, writes ? "tag-token" : "read-token", target); + } + assert.equal(tagJob({ version: "v2.25.0-preview.1" }).result.status, 0); +}); + +test("the tag job is idempotent when the tag is already on the commit and refuses another commit", () => { + const same = tagJob({ env: { STUB_EXISTING: sha } }); + assert.equal(same.result.status, 0, same.result.stderr); + assert.equal(same.created, ""); + const other = tagJob({ env: { STUB_EXISTING: "b".repeat(40) } }); + assert.notEqual(other.result.status, 0); + assert.equal(other.created, ""); +}); + +test("the tag job dereferences an annotated tag before comparing", () => { + const same = tagJob({ env: { STUB_EXISTING: sha, STUB_ANNOTATED: "1" } }); + assert.equal(same.result.status, 0, same.result.stderr); + assert.equal(same.created, ""); + assert.notEqual(tagJob({ env: { STUB_EXISTING: "b".repeat(40), STUB_ANNOTATED: "1" } }).result.status, 0); +}); + +test("a tag created by a concurrent run on the same commit is a success, on another commit a failure", () => { + const ok = tagJob({ env: { STUB_CREATE_FAILS: "1", STUB_EXISTING_AFTER_CREATE_FAIL: sha } }); + assert.equal(ok.result.status, 0, ok.result.stderr); + assert.notEqual(tagJob({ env: { STUB_CREATE_FAILS: "1", STUB_EXISTING_AFTER_CREATE_FAIL: "b".repeat(40) } }).result.status, 0); + assert.notEqual(tagJob({ env: { STUB_CREATE_FAILS: "1" } }).result.status, 0); +}); + +test("the tag job creates nothing for a version that publishes no pointer", () => { + const { result, api } = tagJob({ version: "v2.25.0-rc.1" }); + assert.equal(result.status, 0); + assert.equal(api, ""); +}); + +for (const [name, env] of [ + ["1.x is not frozen", { CLI_PUBLISH_FROZEN: "" }], + ["the credential is missing", { TAG_TOKEN: "" }], + ["the credential is another user's", { STUB_LOGIN: "someone-else" }], + ["the actor variable is malformed", { RELEASE_TAG_ACTOR: "a b" }], + ["the run is on another repository", { GITHUB_REPOSITORY: "fork/DeepSeek-Reasonix" }], + ["the run is on a feature branch", { GITHUB_REF: "refs/heads/feature" }], + ["the run is on main-v2", { GITHUB_REF: "refs/heads/main-v2" }], + ["the studio tag moved off the approved commit", { STUB_TAG_SHA: "c".repeat(40) }], + ["the commit is not on studio history", { STUB_COMPARE: "diverged" }], + ["the approved sha is not a full sha", { APPROVED_SHA: "abc" }], + ["the studio tag does not match the version", { STUDIO_TAG: "studio-v9.9.9" }], +]) { + test(`the tag job creates nothing when ${name}`, () => { + const { result, created } = tagJob({ env }); + assert.notEqual(result.status, 0); + assert.equal(created, ""); + }); +} diff --git a/scripts/testdata/cli-pointer-v2.24.0.json b/scripts/testdata/cli-pointer-v2.24.0.json new file mode 100644 index 0000000000..519347e731 --- /dev/null +++ b/scripts/testdata/cli-pointer-v2.24.0.json @@ -0,0 +1,44 @@ +{ + "tag_name": "v2.24.0", + "prerelease": false, + "html_url": "https://github.com/esengine/DeepSeek-Reasonix/releases/tag/v2.24.0", + "release_notes_url": null, + "assets": [ + { + "name": "reasonix-darwin-amd64.tar.gz", + "browser_download_url": "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v2.24.0/reasonix-darwin-amd64.tar.gz", + "size": 36 + }, + { + "name": "reasonix-darwin-arm64.tar.gz", + "browser_download_url": "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v2.24.0/reasonix-darwin-arm64.tar.gz", + "size": 36 + }, + { + "name": "reasonix-linux-amd64.tar.gz", + "browser_download_url": "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v2.24.0/reasonix-linux-amd64.tar.gz", + "size": 35 + }, + { + "name": "reasonix-linux-arm64.tar.gz", + "browser_download_url": "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v2.24.0/reasonix-linux-arm64.tar.gz", + "size": 35 + }, + { + "name": "reasonix-windows-amd64.zip", + "browser_download_url": "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v2.24.0/reasonix-windows-amd64.zip", + "size": 34 + }, + { + "name": "reasonix-windows-arm64.zip", + "browser_download_url": "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v2.24.0/reasonix-windows-arm64.zip", + "size": 34 + }, + { + "name": "SHA256SUMS", + "browser_download_url": "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v2.24.0/SHA256SUMS", + "size": 564 + } + ] +} + diff --git a/scripts/validate-cli-release-manifest.sh b/scripts/validate-cli-release-manifest.sh index 6d7ece2441..95b8b60fd8 100755 --- a/scripts/validate-cli-release-manifest.sh +++ b/scripts/validate-cli-release-manifest.sh @@ -9,7 +9,7 @@ notes_tag="${5:-$tag}" stable_tag_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' preview_tag_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$' -release_tag_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?)?$' +release_tag_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$' case "$channel" in stable) diff --git a/workers/crash-report/src/desktop_release.test.ts b/workers/crash-report/src/desktop_release.test.ts index 263d8f2e86..10fd27a20a 100644 --- a/workers/crash-report/src/desktop_release.test.ts +++ b/workers/crash-report/src/desktop_release.test.ts @@ -430,6 +430,40 @@ describe("CLI public release gateway", () => { expect(fetchMock.mock.calls[0]?.[0]).toBe("https://dl.reasonix.io/cli/preview/latest.json"); }); + it("serves a 2.x pointer whose archives were published from a studio release", async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify(cliRelease("v2.24.0", false)), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + + const response = await handleCLIRelease("stable"); + const body = await response.json() as { tag_name?: string; assets?: { browser_download_url: string }[] }; + + expect(body.tag_name).toBe("v2.24.0"); + expect(body.assets?.[0]?.browser_download_url).toBe( + "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v2.24.0/reasonix-darwin-amd64.tar.gz", + ); + }); + + it("does not serve a studio-shaped tag or an rc as a public channel", async () => { + for (const [channel, tag, prerelease] of [ + ["stable", "studio-v2.24.0", false], + ["stable", "v2.24.0-rc.1", false], + ["preview", "v2.25.0-rc.1", true], + ] as const) { + const fetchMock = vi.fn(async (url: string) => + url.startsWith("https://dl.reasonix.io/") + ? new Response(JSON.stringify(cliRelease(tag, prerelease)), { status: 200 }) + : new Response(JSON.stringify([cliRelease(tag, prerelease)]), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + + const response = await handleCLIRelease(channel); + + expect(response.status, `${channel} ${tag}`).toBe(502); + } + }); + it("rewrites release notes to the canonical repository tag URL", async () => { const release = cliRelease("v1.18.0", false); release.html_url = "https://evil.invalid/phishing";