Skip to content
Merged
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
173 changes: 173 additions & 0 deletions scripts/release-finalize-stable.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises"
import { spawn } from "node:child_process"

const records = [
["@effectify/hatchet", "packages/hatchet/package.json", "0.1.0"],
["@effectify/node-better-auth", "packages/node/better-auth/package.json", "0.5.12"],
["@effectify/prisma", "packages/prisma/package.json", "1.1.13"],
["@effectify/react-query", "packages/react/query/package.json", "1.0.0"],
["@effectify/react-router", "packages/react/router/package.json", "0.6.0"],
["@effectify/react-router-better-auth", "packages/react/router-better-auth/package.json", "0.5.12"],
["@effectify/solid-query", "packages/solid/query/package.json", "0.5.13"],
]
const expectedSha = process.env.EXPECTED_SHA ?? ""
const maxReads = 6
const delayMs = Number(process.env.NPM_READ_DELAY_MS ?? (Number(process.env.NPM_READ_DELAY ?? 10) * 1000))
const commandTimeoutMs = Number(process.env.FINALIZE_COMMAND_TIMEOUT_MS ?? 60_000)
const httpTimeoutMs = Number(process.env.FINALIZE_HTTP_TIMEOUT_MS ?? 30_000)
const outputLimit = Number(process.env.FINALIZE_OUTPUT_LIMIT ?? 1024 * 1024)
const cliArguments = process.argv.slice(2)
const preflight = cliArguments.includes("--preflight")
const jsonOutput = cliArguments.includes("--json")

function fail(message) { throw new Error(message) }
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) }
function run(file, args, { ok = [0] } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(file, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] })
let stdout = Buffer.alloc(0), stderr = Buffer.alloc(0), excessive = false
const append = (current, chunk) => {
if (current.length + chunk.length > outputLimit) { excessive = true; child.kill("SIGKILL"); return current }
return Buffer.concat([current, chunk])
}
child.stdout.on("data", (x) => { stdout = append(stdout, x) })
child.stderr.on("data", (x) => { stderr = append(stderr, x) })
const timer = setTimeout(() => child.kill("SIGKILL"), commandTimeoutMs)
child.on("error", (error) => { clearTimeout(timer); reject(new Error(`${file} execution failed: ${error.message}`)) })
child.on("close", (code, signal) => {
clearTimeout(timer)
const result = { code, signal, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") }
if (excessive) reject(new Error(`${file} output exceeded bound`))
else if (signal) reject(new Error(`${file} timed out or terminated (${signal})`))
else if (!ok.includes(code)) reject(new Error(`${file} failed (${code})`))
else resolve(result)
})
})
}
function parseJson(text, label) { try { return JSON.parse(text) } catch { fail(`${label} returned malformed JSON`) } }
async function manifest(name, path, version) {
let value
try { value = parseJson(await readFile(path, "utf8"), `manifest ${name}`) } catch (error) { fail(`merged manifest execution or parse failed for ${name}: ${error.message}`) }
const valid = value && typeof value === "object" && !Array.isArray(value) && typeof value.name === "string" && typeof value.version === "string"
if (!valid || value.name !== name || value.version !== version) fail(`merged manifest identity mismatch for ${name}: actual=${JSON.stringify({ name: valid ? value.name : null, version: valid ? value.version : null })} expected=${JSON.stringify({ name, version })}`)
}
async function npmState(name, version) {
try {
const versionsDoc = (await run("npm", ["view", name, "versions", "--json"])).stdout
const latestDoc = (await run("npm", ["view", name, "dist-tags.latest", "--json"])).stdout
const versions = parseJson(versionsDoc, `${name} versions`), latest = parseJson(latestDoc, `${name} latest`)
if (!((typeof versions === "string") || (Array.isArray(versions) && versions.every((x) => typeof x === "string"))) || typeof latest !== "string") return { kind: "unknown" }
const present = Array.isArray(versions) ? versions.includes(version) : versions === version
return !present ? { kind: "absent" } : latest === version ? { kind: "exact" } : { kind: "divergent" }
} catch { return { kind: "unknown" } }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
async function npmBounded(name, version, { acceptAbsent = false } = {}) {
let state
for (let attempt = 1; attempt <= maxReads; attempt++) {
state = await npmState(name, version)
if (state.kind === "exact" || (acceptAbsent && state.kind === "absent")) return state
if (attempt < maxReads) await sleep(delayMs)
}
if (state.kind === "absent") fail(`npm version remained absent after ${maxReads} attempts for ${name}`)
fail(state.kind === "divergent" ? `permanent latest divergence for ${name}` : `npm state unreadable after ${maxReads} attempts for ${name}`)
}
function parseTag(text, tag) {
const direct = [], peeled = [], directRef = `refs/tags/${tag}`, peeledRef = `${directRef}^{}`
if (text === "") return { kind: "absent" }
for (const line of text.split("\n")) {
if (!line) continue
const match = line.match(/^([0-9a-f]{40})\t([^\s]+)$/)
if (!match) return { kind: "unknown" }
if (match[2] === directRef) direct.push(match[1]); else if (match[2] === peeledRef) peeled.push(match[1]); else return { kind: "unknown" }
}
return direct.length === 1 && peeled.length === 1 && peeled[0] === expectedSha ? { kind: "exact" } : { kind: "divergent" }
}
async function tagState(tag) {
let result
try { result = await run("git", ["ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`]) } catch { return { kind: "unknown" } }
return parseTag(result.stdout, tag)
}
async function localTagState(tag) {
let result
try { result = await run("git", ["for-each-ref", "--format=%(objecttype)%09%(*objectname)", `refs/tags/${tag}`]) } catch { return { kind: "unknown" } }
if (result.stdout === "") return { kind: "absent" }
const lines = result.stdout.trimEnd().split("\n")
if (lines.length !== 1) return { kind: "divergent" }
const match = lines[0].match(/^tag\t([0-9a-f]{40})$/)
return match && match[1] === expectedSha ? { kind: "exact" } : { kind: "divergent" }
}
function repository() {
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY
fail("GITHUB_REPOSITORY is required")
}
async function github(method, path, body) {
const controller = new AbortController(), timer = setTimeout(() => controller.abort(), httpTimeoutMs)
try {
const options = {
method, signal: controller.signal,
headers: { accept: "application/vnd.github+json", authorization: `Bearer ${process.env.GITHUB_TOKEN ?? ""}`, "content-type": "application/json", "user-agent": "effectify-release-finalizer", "x-github-api-version": "2022-11-28" },
}
if (body !== undefined) options.body = JSON.stringify(body)
const response = await fetch(`${process.env.GITHUB_API_URL ?? "https://api.github.com"}/repos/${repository()}${path}`, options)
const text = await response.text()
return { status: response.status, text }
} catch (error) { fail(`GitHub transport failure: ${error.message}`) } finally { clearTimeout(timer) }
}
async function releaseState(tag) {
const result = await github("GET", `/releases/tags/${encodeURIComponent(tag)}`)
if (result.status === 404) return { kind: "absent" }
if (result.status !== 200) return { kind: "unknown", status: result.status }
const value = parseJson(result.text, `GitHub Release ${tag}`)
return value && typeof value === "object" && !Array.isArray(value) && value.tag_name === tag && value.draft === false && value.prerelease === false ? { kind: "exact" } : { kind: "divergent" }
}
async function inspect() {
await run("git", ["fetch", "origin", "master:refs/remotes/origin/master", "--no-tags"])
const head = (await run("git", ["rev-parse", "HEAD"])).stdout.trim(), origin = (await run("git", ["rev-parse", "origin/master"])).stdout.trim()
if (head !== expectedSha) fail("HEAD does not match expected SHA")
if (origin !== expectedSha) fail("origin/master does not match expected SHA")
const states = []
for (const [name, path, version] of records) {
await manifest(name, path, version)
const npm = await npmBounded(name, version, { acceptAbsent: true }), tag = await tagState(`${name}@${version}`), release = await releaseState(`${name}@${version}`)
for (const [label, state] of [["tag", tag], ["GitHub Release", release]]) if (!['exact','absent'].includes(state.kind)) fail(`${label} state is ${state.kind} for ${name}@${version}${state.status ? ` (HTTP ${state.status})` : ""}`)
states.push({ name, version, npm: npm.kind, tag: tag.kind, release: release.kind })
}
return states
}
async function main() {
if (cliArguments.some((x) => !["--preflight", "--json"].includes(x))) fail("unknown argument")
if (jsonOutput && !preflight) fail("--json requires --preflight")
if (!/^[0-9a-f]{40}$/.test(expectedSha)) fail("FINALIZE requires full lowercase expected SHA")
const states = await inspect()
if (preflight) { process.stdout.write(`${JSON.stringify({ ok: true, expectedSha, states })}\n`); return }
const missingTags = states.filter((x) => x.tag === "absent")
const localTags = []
for (const item of missingTags) {
const tag = `${item.name}@${item.version}`, local = await localTagState(tag)
if (!['exact','absent'].includes(local.kind)) fail(`local tag state is ${local.kind} for ${tag}`)
localTags.push({ item, tag, local: local.kind })
}
if (localTags.some((x) => x.local === "absent")) {
await run("git", ["config", "user.name", "github-actions[bot]"])
await run("git", ["config", "user.email", "github-actions[bot]@users.noreply.github.com"])
}
for (const { tag, local } of localTags) if (local === "absent") await run("git", ["tag", "-a", tag, expectedSha, "-m", tag])
if (missingTags.length) {
const refs = missingTags.map((x) => `refs/tags/${x.name}@${x.version}:refs/tags/${x.name}@${x.version}`)
try { await run("git", ["push", "--atomic", "origin", ...refs]) } catch { /* response loss is reconciled below */ }
}
for (const item of states) if ((await tagState(`${item.name}@${item.version}`)).kind !== "exact") fail(`remote tag postverification failed for ${item.name}@${item.version}`)
for (const item of states.filter((x) => x.release === "absent")) {
const result = await github("POST", "/releases", { tag_name: `${item.name}@${item.version}`, generate_release_notes: true, draft: false, prerelease: false })
if (![201, 422].includes(result.status)) fail(`GitHub Release creation failed for ${item.name}@${item.version} (HTTP ${result.status})`)
}
for (const item of states) if ((await releaseState(`${item.name}@${item.version}`)).kind !== "exact") fail(`GitHub Release postverification failed for ${item.name}@${item.version}`)
const missing = states.filter((x) => x.npm === "absent").map((x) => x.name)
if (missing.length) await run("pnpm", ["nx", "release", "publish", `--projects=${missing.join(",")}`])
for (const item of states) { const state = await npmBounded(item.name, item.version); if (state.kind !== "exact") fail(`npm did not converge for ${item.name}`) }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href
if (isMain) main().catch((error) => { process.stderr.write(`::error::${error.message}\n`); process.exitCode = 1 })

export { parseJson, parseTag }
97 changes: 1 addition & 96 deletions scripts/release-finalize-stable.sh
Original file line number Diff line number Diff line change
@@ -1,98 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail

: "${EXPECTED_SHA:?EXPECTED_SHA is required}"
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::FINALIZE requires full lowercase expected SHA"; exit 1; }
MAX_NPM_READS=6
NPM_READ_DELAY=${NPM_READ_DELAY:-10}
WORK=$(mktemp -d)
RECORDS="$WORK/records"
printf '%s\n' \
'@effectify/hatchet|packages/hatchet/package.json|0.1.0' \
'@effectify/node-better-auth|packages/node/better-auth/package.json|0.5.12' \
'@effectify/prisma|packages/prisma/package.json|1.1.13' \
'@effectify/react-query|packages/react/query/package.json|1.0.0' \
'@effectify/react-router|packages/react/router/package.json|0.6.0' \
'@effectify/react-router-better-auth|packages/react/router-better-auth/package.json|0.5.12' \
'@effectify/solid-query|packages/solid/query/package.json|0.5.13' > "$RECORDS"

fail() { echo "::error::$*" >&2; exit 1; }
manifest_ok() {
node -e 'const fs=require("node:fs");const [path,name,version]=process.argv.slice(1);let v;try{v=JSON.parse(fs.readFileSync(path,"utf8"))}catch{process.exit(2)}if(!v||typeof v!=="object"||Array.isArray(v)||typeof v.name!=="string"||typeof v.version!=="string"||v.name!==name||v.version!==version){process.stdout.write(`actual=${JSON.stringify({name:typeof v?.name==="string"?v.name:null,version:typeof v?.version==="string"?v.version:null})} expected=${JSON.stringify({name,version})}`);process.exit(1)}' "$1" "$2" "$3"
}
npm_state() {
local name=$1 version=$2 versions latest
versions=$(npm view "$name" versions --json) || return 2
latest=$(npm view "$name" dist-tags.latest --json) || return 2
printf '%s\n%s' "$versions" "$latest" | node -e 'const fs=require("node:fs"),[version]=process.argv.slice(1),lines=fs.readFileSync(0,"utf8").split("\n"),vs=JSON.parse(lines.shift()),latest=JSON.parse(lines.join("\n"));if(!(typeof vs==="string"||Array.isArray(vs)&&vs.every(x=>typeof x==="string"))||typeof latest!=="string")process.exit(2);const present=Array.isArray(vs)?vs.includes(version):vs===version;process.exit(present?(latest===version?0:3):1)' "$version"
}
read_npm_bounded() {
local name=$1 version=$2 attempt status
for attempt in $(seq 1 "$MAX_NPM_READS"); do
set +e; npm_state "$name" "$version"; status=$?; set -e
[ "$status" = 0 ] && return 0
[ "$status" = 1 ] && return 1
[ "$attempt" = "$MAX_NPM_READS" ] || sleep "$NPM_READ_DELAY"
done
return "$status"
}
verify_tag() {
local tag=$1 remote direct peeled sha
remote=$(git ls-remote --tags origin "refs/tags/$tag" "refs/tags/$tag^{}") || fail "unknown remote tag state for $tag"
[ -n "$remote" ] || return 1
direct=$(printf '%s\n' "$remote" | awk -v r="refs/tags/$tag" '$2==r{n++}END{print n+0}')
peeled=$(printf '%s\n' "$remote" | awk -v r="refs/tags/$tag^{}" '$2==r{n++}END{print n+0}')
sha=$(printf '%s\n' "$remote" | awk -v r="refs/tags/$tag^{}" '$2==r{print $1}')
[ "$direct" = 1 ] && [ "$peeled" = 1 ] && [ "$sha" = "$EXPECTED_SHA" ] || fail "divergent, partial, or lightweight tag $tag"
}
verify_release() {
local tag=$1 value status err="$WORK/gh-error"
set +e; value=$(gh release view "$tag" --json tagName,isDraft,isPrerelease 2>"$err"); status=$?; set -e
if [ "$status" = 0 ]; then
printf '%s' "$value" | node -e 'const fs=require("node:fs"),tag=process.argv[1],v=JSON.parse(fs.readFileSync(0,"utf8"));if(!v||typeof v!=="object"||Array.isArray(v)||v.tagName!==tag||v.isDraft!==false||v.isPrerelease!==false)process.exit(1)' "$tag" || fail "divergent, draft, or prerelease GitHub Release $tag"
return 0
fi
[ "$status" = 1 ] && grep -Fqi 'release not found' "$err" && return 1
fail "unknown GitHub Release state for $tag"
}

git fetch origin master:refs/remotes/origin/master --no-tags
[ "$(git rev-parse HEAD)" = "$EXPECTED_SHA" ] || fail "HEAD does not match expected SHA"
[ "$(git rev-parse origin/master)" = "$EXPECTED_SHA" ] || fail "origin/master does not match expected SHA"
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
: > "$WORK/missing-projects"; : > "$WORK/missing-tags"; : > "$WORK/missing-releases"
while IFS='|' read -r NAME MANIFEST_PATH VERSION; do
DETAIL=$(manifest_ok "$MANIFEST_PATH" "$NAME" "$VERSION") || { STATUS=$?; [ "$STATUS" = 1 ] && fail "merged manifest identity mismatch for $NAME: $DETAIL"; fail "merged manifest execution or parse failed for $NAME"; }
if read_npm_bounded "$NAME" "$VERSION"; then STATUS=0; else STATUS=$?; fi
case $STATUS in 0) ;; 1) printf '%s\n' "$NAME" >> "$WORK/missing-projects" ;; 3) fail "permanent latest divergence for $NAME" ;; *) fail "npm state unreadable after $MAX_NPM_READS attempts for $NAME" ;; esac
TAG="$NAME@$VERSION"
verify_tag "$TAG" || printf '%s\n' "$TAG" >> "$WORK/missing-tags"
verify_release "$TAG" || printf '%s\n' "$TAG" >> "$WORK/missing-releases"
done < "$RECORDS"

TAG_REFS=()
while IFS= read -r TAG; do [ -n "$TAG" ] || continue; git show-ref --verify --quiet "refs/tags/$TAG" && fail "local tag collision $TAG"; git tag -a "$TAG" "$EXPECTED_SHA" -m "$TAG"; TAG_REFS+=("refs/tags/$TAG:refs/tags/$TAG"); done < "$WORK/missing-tags"
if [ ${#TAG_REFS[@]} -gt 0 ]; then git push --atomic origin "${TAG_REFS[@]}" || echo "::warning::atomic tag push failed; post-verifying remote state" >&2; fi
while IFS='|' read -r NAME _ VERSION; do TAG="$NAME@$VERSION"; verify_tag "$TAG" || fail "remote tag postverification failed for $TAG"; done < "$RECORDS"
while IFS= read -r TAG; do [ -n "$TAG" ] || continue; gh release create "$TAG" --verify-tag --generate-notes || fail "GitHub Release creation failed for $TAG"; done < "$WORK/missing-releases"
while IFS='|' read -r NAME _ VERSION; do verify_release "$NAME@$VERSION" || fail "GitHub Release postverification failed for $NAME@$VERSION"; done < "$RECORDS"
MISSING_PROJECTS=$(paste -sd, "$WORK/missing-projects")
if [ -n "$MISSING_PROJECTS" ]; then pnpm nx release publish "--projects=$MISSING_PROJECTS"; fi
for ATTEMPT in $(seq 1 "$MAX_NPM_READS"); do
REMAINING=0
DIVERGENT=""
while IFS='|' read -r NAME _ VERSION; do
set +e; npm_state "$NAME" "$VERSION"; STATUS=$?; set -e
if [ "$STATUS" != 0 ]; then
[ "$STATUS" = 3 ] && DIVERGENT=$NAME
REMAINING=$((REMAINING+1))
fi
done < "$RECORDS"
[ "$REMAINING" = 0 ] && exit 0
if [ "$ATTEMPT" = "$MAX_NPM_READS" ]; then
[ -n "$DIVERGENT" ] && fail "permanent latest divergence for $DIVERGENT"
fail "npm did not converge: $REMAINING"
fi
sleep "$NPM_READ_DELAY"
done
exec node "$(dirname "$0")/release-finalize-stable.mjs" "$@"
Loading
Loading