-
Notifications
You must be signed in to change notification settings - Fork 1
fix(release): harden stable finalization adapters #266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" } } | ||
| } | ||
| 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}`) } | ||
|
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 } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" "$@" |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.