mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
ci(release): the Homebrew cask is written only after the CLI release it points at exists, and never moves backwards
cli-channels and cli-pointer both waited only for cli-tag, so the cask could be pushed while the v<version> release holding its archives was still being created, and stayed pointing at nothing if cli-pointer then failed. The cask step also overwrote the tap whatever its version, so a workflow_dispatch of an older tag moved Homebrew backwards; it now skips a tap that is ahead and fails closed when the tap's version cannot be read.
This commit is contained in:
1 parent
bfe2ca68b8
commit
f89c934e47
7 files changed
+161
-7
No files matched your search
@@ -84,7 +84,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: node --test scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
- run: node --test scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.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
|
||||
|
||||
@@ -1215,13 +1215,13 @@ jobs:
|
||||
|
||||
# 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
|
||||
# exists, because the cask points at its archives. A candidate goes to npm's
|
||||
# `next` tag and leaves the tap alone: brew has no prerelease channel, so a
|
||||
# candidate cask would become the default install.
|
||||
# STUDIO_PUBLISHES_CLI=true hands them here. They run once cli-pointer has
|
||||
# created the CLI release, because the cask points at its archives. A candidate
|
||||
# goes to npm's `next` tag and leaves the tap alone: brew has no prerelease
|
||||
# channel, so a candidate cask would become the default install.
|
||||
cli-channels:
|
||||
name: publish the CLI to npm and Homebrew
|
||||
needs: [resolve, publish, cli-gate, cli-tag]
|
||||
needs: [resolve, publish, cli-gate, cli-tag, cli-pointer]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# id-token lets npm publish --provenance attach a Sigstore attestation; the
|
||||
@@ -1260,6 +1260,18 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 "https://x-access-token:${TAP_TOKEN}@github.com/esengine/homebrew-reasonix.git" tap
|
||||
decision="$(node scripts/brew-cask-guard.mjs tap/Casks/reasonix.rb "${VERSION#v}")"
|
||||
case "$decision" in
|
||||
write) ;;
|
||||
skip)
|
||||
echo "the tap's cask is newer than ${VERSION#v}; leaving it"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "::error::unexpected cask guard decision: '$decision'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
bash scripts/render-brew-cask.sh "${VERSION#v}" "$TAG" cli-archives/SHA256SUMS > tap/Casks/reasonix.rb
|
||||
cd tap
|
||||
git add Casks/reasonix.rb
|
||||
|
||||
@@ -86,6 +86,13 @@ func TestCLIGateBlocksEveryCLIChannel(t *testing.T) {
|
||||
t.Fatalf("%s does not wait for cli-tag", name)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, n := range mappingValue(mappingValue(jobs, "cli-channels"), "needs").Content {
|
||||
found = found || n.Value == "cli-pointer"
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("cli-channels does not wait for cli-pointer: the cask would name archives that may not exist yet")
|
||||
}
|
||||
}
|
||||
|
||||
var releaseTagSecrets = []string{"RELEASE_TAG_TOKEN"}
|
||||
|
||||
@@ -94,6 +94,10 @@ Once a GitHub release `vX.Y.Z` exists, the v1.39.5 client's GitHub-list fallback
|
||||
|
||||
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.
|
||||
|
||||
`cli-channels` waits for `cli-pointer`, because the Homebrew cask names the archives of the `vX.Y.Z` release it creates. If `cli-pointer` fails, nothing reaches npm or Homebrew until the `workflow_dispatch` recovery run finishes it.
|
||||
|
||||
The cask is written only if the tap's current version is not higher than the release's (semver order, prereleases included); a `workflow_dispatch` of an older tag leaves it alone, and a cask whose version cannot be read fails the step.
|
||||
|
||||
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.
|
||||
|
||||
No other job requests `id-token` or references these two secrets, and no workflow file reads them but `release-studio.yml`'s `cli-channels`; `cmd/signpath-contract` tests this over the parsed YAML of every workflow, and that `cli-channels` declares no environment.
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ function parseSemver(version) {
|
||||
};
|
||||
}
|
||||
|
||||
function compareSemver(a, b) {
|
||||
export function compareSemver(a, b) {
|
||||
const aa = parseSemver(a);
|
||||
const bb = parseSemver(b);
|
||||
for (let i = 0; i < aa.core.length; i += 1) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFileSync, realpathSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { compareSemver } from "../npm/publish.mjs";
|
||||
|
||||
const VERSION_LINE = /^\s*version "([^"]+)"\s*$/m;
|
||||
|
||||
// Returns "write" when the candidate is not older than the tap's cask, "skip"
|
||||
// when the tap is ahead; throws when the tap's version cannot be read.
|
||||
export function caskDecision(caskText, candidate) {
|
||||
const current = caskText.match(VERSION_LINE)?.[1];
|
||||
if (!current) throw new Error("the tap's cask has no readable version line; refusing to overwrite it");
|
||||
return compareSemver(candidate, current) < 0 ? "skip" : "write";
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
||||
const [file, candidate] = process.argv.slice(2);
|
||||
if (!file || !candidate) throw new Error("usage: brew-cask-guard.mjs CASK_FILE VERSION");
|
||||
try {
|
||||
console.log(caskDecision(readFileSync(file, "utf8"), candidate));
|
||||
} catch (error) {
|
||||
console.error(`::error::${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { caskDecision } from "./brew-cask-guard.mjs";
|
||||
|
||||
const cask = (v) => `cask "reasonix" do\n version "${v}"\nend\n`;
|
||||
|
||||
test("a candidate equal to or newer than the tap's cask is written", () => {
|
||||
assert.equal(caskDecision(cask("2.27.0"), "2.27.0"), "write");
|
||||
assert.equal(caskDecision(cask("1.39.7"), "2.28.0"), "write");
|
||||
assert.equal(caskDecision(cask("2.9.0"), "2.10.0"), "write");
|
||||
});
|
||||
|
||||
test("an older candidate never moves the cask backwards", () => {
|
||||
assert.equal(caskDecision(cask("2.28.0"), "2.27.0"), "skip");
|
||||
assert.equal(caskDecision(cask("2.10.0"), "2.9.0"), "skip");
|
||||
assert.equal(caskDecision(cask("2.28.0"), "2.28.0-preview.1"), "skip");
|
||||
});
|
||||
|
||||
test("a cask without a readable version fails closed", () => {
|
||||
assert.throws(() => caskDecision("cask \"reasonix\" do\nend\n", "2.28.0"), /no readable version/);
|
||||
});
|
||||
|
||||
test("the command prints the decision and fails with an error annotation when unreadable", () => {
|
||||
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), "brew-cask-guard.mjs");
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "cask-"));
|
||||
const file = path.join(dir, "reasonix.rb");
|
||||
writeFileSync(file, cask("2.28.0"));
|
||||
const run = (f, v) => spawnSync("node", [script, f, v], { encoding: "utf8" });
|
||||
assert.equal(run(file, "2.27.0").stdout.trim(), "skip");
|
||||
const missing = run(path.join(dir, "absent.rb"), "2.27.0");
|
||||
assert.notEqual(missing.status, 0);
|
||||
assert.match(missing.stderr, /::error::/);
|
||||
});
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const workflowLines = readFileSync(path.join(here, "../.github/workflows/release-studio.yml"), "utf8").split("\n");
|
||||
|
||||
function stepRun(name) {
|
||||
const at = workflowLines.findIndex((l) => l.trim() === `- name: ${name}`);
|
||||
assert.ok(at >= 0, `no step named ${name}`);
|
||||
const run = workflowLines.findIndex((l, i) => i > at && l.trim() === "run: |");
|
||||
const indent = workflowLines[run].search(/\S/) + 2;
|
||||
const body = [];
|
||||
for (let i = run + 1; i < workflowLines.length; i += 1) {
|
||||
if (workflowLines[i].trim() !== "" && workflowLines[i].search(/\S/) < indent) break;
|
||||
body.push(workflowLines[i].slice(indent));
|
||||
}
|
||||
return body.join("\n");
|
||||
}
|
||||
|
||||
// Runs the workflow's own Homebrew step with git stubbed: clone yields a tap
|
||||
// holding `tapCask`, every other git call is a no-op.
|
||||
function runStep(tapCask, { brokenNode = false } = {}) {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "cask-step-"));
|
||||
const bin = path.join(dir, "bin");
|
||||
mkdirSync(bin);
|
||||
writeFileSync(
|
||||
path.join(bin, "git"),
|
||||
`#!/bin/sh\nif [ "$1" = clone ]; then mkdir -p tap/Casks; printf '%s' "$STUB_CASK" > tap/Casks/reasonix.rb; fi\nexit 0\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
if (brokenNode) writeFileSync(path.join(bin, "node"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
mkdirSync(path.join(dir, "cli-archives"));
|
||||
const names = ["darwin-arm64.tar.gz", "darwin-amd64.tar.gz", "linux-arm64.tar.gz", "linux-amd64.tar.gz"];
|
||||
writeFileSync(
|
||||
path.join(dir, "cli-archives/SHA256SUMS"),
|
||||
names.map((n) => `${"a".repeat(64)} reasonix-${n}`).join("\n") + "\n",
|
||||
);
|
||||
symlinkSync(here, path.join(dir, "scripts"));
|
||||
symlinkSync(path.join(here, "../npm"), path.join(dir, "npm"));
|
||||
const result = spawnSync("bash", ["-c", stepRun("Update the Homebrew cask")], {
|
||||
cwd: dir,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, STUB_CASK: tapCask, TAP_TOKEN: "t", VERSION: "v2.27.0", TAG: "v2.27.0" },
|
||||
});
|
||||
return { ...result, cask: readFileSync(path.join(dir, "tap/Casks/reasonix.rb"), "utf8") };
|
||||
}
|
||||
|
||||
test("the step leaves a newer cask untouched", () => {
|
||||
const r = runStep(cask("2.28.0"));
|
||||
assert.equal(r.status, 0, `${r.stdout} ${r.stderr}`);
|
||||
assert.equal(r.cask, cask("2.28.0"));
|
||||
});
|
||||
|
||||
test("the step fails closed and leaves the cask untouched when the tap version is unreadable", () => {
|
||||
const unreadable = 'cask "reasonix" do\nend\n';
|
||||
const r = runStep(unreadable);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.equal(r.cask, unreadable);
|
||||
});
|
||||
|
||||
test("the step fails closed when the guard itself cannot run", () => {
|
||||
const r = runStep(cask("1.0.0"), { brokenNode: true });
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.equal(r.cask, cask("1.0.0"));
|
||||
});
|
||||
|
||||
test("the step rewrites an older cask", () => {
|
||||
const r = runStep(cask("2.26.0"));
|
||||
assert.equal(r.status, 0, `${r.stdout} ${r.stderr}`);
|
||||
assert.match(r.cask, /version "2\.27\.0"/);
|
||||
});
|
||||
Reference in new issue
Block a user