diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c168ee6d449..7dbf30bb86e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3682,6 +3682,22 @@ jobs: - name: Post-publish npm verification self-test run: node scripts/release-verify-npm.mjs --self-test + # Release version-commit selection (ADR-0125 D1 as amended 2026-09-29). + # release.yml's publish lane queues a deployment only on the push that + # CARRIES the version commit, publishes that commit rather than a later + # push's head, and cancels a push-lane prompt still waiting for a version + # already on npm. scripts/release-pending-publish.mjs holds all three + # decisions. Its production path runs a few times a year, on a runner, + # against a history nobody can rewind, so its --self-test is the only + # instrument on that logic: throwaway repositories for a version commit + # then two landings, a merge-queue batch, a published version, a + # force-push and a shallow clone, each asserting the selected sha. + # + # Invoked as `node` rather than through a `pnpm check:*` alias: see the + # GATE INVOCATION IDIOM note at the top of this file. + - name: Release version-commit selection self-test + run: node scripts/release-pending-publish.mjs --self-test + # #3825 Node-version drift guard: a runtime pin is 18 separate string # literals across .github/workflows, so a split is invisible until someone # greps for it. One did open — every PR gate sat on Node 20 (EOL diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 254cfba1c1f..898be15d4d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,24 +49,35 @@ name: Release # an `if:` someone can get wrong. # NOT on push — see the section below. # push to main → `release-integrity` audits ONLY the version at -# `github.sha`. Never publishes, never -# pushes a tag. May backfill GitHub +# `github.sha`, and names the VERSION +# COMMIT — the landing that brought +# main to that version. Never publishes, +# never pushes a tag. May backfill GitHub # Releases / the ADR-0087 D4 asset / # the runtime image — but only for a # version ALREADY fully on npm, which # is repair that cannot mint anything. # → `publish` the ONLY job that runs # `changeset publish` or pushes a -# version tag. It starts only when -# `release-integrity` reports that -# main's version is ABSENT from npm — -# i.e. the Version Packages PR has just -# merged — and it is then held, whole, -# at `environment: release` until a -# required reviewer approves it. +# version tag. It starts only on the +# push that CARRIES the version commit, +# while that version is absent from +# npm (ADR-0125 D1 as amended +# 2026-09-29), is then held, whole, at +# `environment: release` until a +# required reviewer approves it, and +# checks out and publishes THE VERSION +# COMMIT — never a later push's head. +# → `stale-prompts` cancels a push-lane publish prompt +# still waiting for a version that is +# already on npm. Mints nothing; never +# touches a job that has started. # workflow_dispatch → `publish` the repair lane. Takes no version; -# (WITHOUT audits main exactly as the push lane -# `refresh_version_pr`) does. Same environment gate. +# (WITHOUT audits main the same way, minus the +# `refresh_version_pr`) push range (a dispatch is one human +# act, not one per landing), and +# publishes the same version commit. +# Same environment gate. # # WHY `version-pr` LEFT THE PUSH TRIGGER (#11233, 2026-08-23) # ---------------------------------------------------------- @@ -759,9 +770,14 @@ jobs: # earlier one and a THIRD push arrives, this job is cancelled — and `publish` # needs it, so the release quietly does not queue. It is narrow (the landing # run has to be the pending one, not the running one) and it is visible (no - # approval request arrives) and it is recoverable without any special - # handling: the version is still absent from npm, so the `workflow_dispatch` - # repair lane re-audits and queues the same deployment. + # approval request arrives, and every later push's audit says in its notice + # that main's version is unpublished and was not queued THERE) and it is + # recoverable without any special handling: the version is still absent + # from npm, so the `workflow_dispatch` repair lane re-audits and queues the + # same deployment, on the same version commit. ⚠️ Until 2026-09-29 the next + # landing re-queued it by accident — that accident was the defect the + # amended D1 removes (every landing queued a publish of its OWN head), so + # the dispatch is now the only recovery, by design. concurrency: group: release-integrity-${{ github.ref }} cancel-in-progress: false @@ -774,14 +790,30 @@ jobs: # version and its runtime image is missing. published: ${{ steps.audit.outputs.image-missing }} cli-version: ${{ steps.audit.outputs.version }} - # THE release predicate (ADR-0125 D1): 'true' exactly when main's - # @objectstack/cli version is absent from npm — i.e. the Version Packages - # PR has just merged and nothing has shipped it yet. False on every - # ordinary landing, which is why an ordinary merge queues no deployment. + # The commit `publish` checks out: the newest first-parent commit at which + # @objectstack/cli's version differs from its parent's — the landing that + # brought main to the version above (ADR-0125 D1 as amended 2026-09-29). + # Computed on every audited event, because it is what the approval + # authorises on both lanes. + version-commit: ${{ steps.audit.outputs.version-commit }} + # THE release predicate (ADR-0125 D1, amended 2026-09-29): on a push, + # 'true' exactly when THIS push carries the version commit and that + # version is absent from npm; on the repair dispatch, exactly when the + # version is absent from npm. The old predicate — "absent from npm" alone + # — stayed true on every landing until the publish finished, so each one + # queued its own publish of its own head (17.5.0 shipped 8 PRs past its + # version commit). A later landing now queues nothing and evicts nothing. publish-pending: ${{ steps.audit.outputs.publish-pending }} steps: + # `fetch-depth: 0` is load-bearing: the version commit is found by walking + # main's first-parent history, and the push range by ancestry against + # `github.event.before`. On a shallow clone the graft boundary would read + # as "the version changed here" — scripts/release-pending-publish.mjs + # refuses a shallow clone rather than answer from one. - name: Checkout repository uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v7 @@ -801,6 +833,10 @@ jobs: id: audit env: SHA: ${{ github.sha }} + EVENT: ${{ github.event_name }} + # On a push, main's tip before this push landed (all zeros on a branch + # creation); empty on a dispatch, which has no push range. + BEFORE: ${{ github.event.before }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | version=$(git show "${SHA}:packages/cli/package.json" | jq -r '.version') @@ -822,27 +858,80 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "main (${SHA}) carries @objectstack/cli@${version}" + # ── the version commit, and whether THIS event queues its publish ─ + # ADR-0125 D1 as amended 2026-09-29. The logic, and the pins that hold + # it (a version commit then two landings, a merge-queue batch, a + # published version, a force-push, a shallow clone), live in + # scripts/release-pending-publish.mjs, whose --self-test lint.yml + # runs. It also asks npm (present / absent / unknown), so the npm + # branch below reads its answer rather than asking twice. + if ! selection=$(node scripts/release-pending-publish.mjs select --event "$EVENT" --head "$SHA" ${BEFORE:+--before "$BEFORE"}); then + echo "::error::could not select the version commit for ${SHA} (reason above). Refusing to decide whether a release is pending." + exit 1 + fi + jq . <<<"$selection" + selected_version=$(jq -r '.version' <<<"$selection") + version_commit=$(jq -r '.versionCommit' <<<"$selection") + npm_state=$(jq -r '.npm' <<<"$selection") + pending=$(jq -r '.pending' <<<"$selection") + reason=$(jq -r '.reason' <<<"$selection") + range_detail=$(jq -r '.range.detail' <<<"$selection") + if [ "$selected_version" != "$version" ]; then + echo "::error::the version-commit walk read @objectstack/cli@${selected_version} at ${SHA}, this step read ${version}. Two readers of one object disagree; refusing to act on either." + exit 1 + fi + echo "version-commit=${version_commit}" >> "$GITHUB_OUTPUT" + echo "version commit: ${version_commit} — the landing that brought main to ${version}" + # ── npm ─────────────────────────────────────────────────────────── - if ! npm view "@objectstack/cli@$version" version >/dev/null 2>&1; then - # THE predicate (ADR-0125 D1). Not on npm = the Version Packages PR - # has just merged and nothing has shipped it. This job still cannot - # publish anything — it says so and stays green; the `publish` job - # below reads this output, and IT stops dead on the `release` - # environment until a required reviewer approves it. - echo "publish-pending=true" >> "$GITHUB_OUTPUT" - echo "::notice::main carries @objectstack/cli@${version}, which is NOT on npm. A deployment is queued and is waiting for a maintainer to approve the 'release' environment." + # `unknown` (npm could not be read) keeps this step's historical + # reading, "not on npm": no backfill runs off a guess, and a pending + # publish is a prompt a human still has to approve. + if [ "$npm_state" != 'present' ]; then + if [ "$pending" = 'true' ]; then + # THE predicate (ADR-0125 D1, amended). This push carries the + # version commit and nothing has shipped it. This job still cannot + # publish anything — it says so and stays green; the `publish` job + # below reads this output, and IT stops dead on the `release` + # environment until a required reviewer approves it. + echo "publish-pending=true" >> "$GITHUB_OUTPUT" + echo "::notice::main carries @objectstack/cli@${version}, which is NOT on npm; this ${EVENT} queues its publish on the version commit ${version_commit}. A deployment is queued and is waiting for a maintainer to approve the 'release' environment." + { + echo "## Release ${version} is waiting for your approval" + echo + echo "main (\`${SHA}\`) carries **@objectstack/cli@${version}**, which is not on npm." + echo "Its version commit is \`${version_commit}\`." + echo + echo "The **Publish ${version} to npm** job below is held at the \`release\`" + echo "environment gate. Nothing has been checked out, built or published —" + echo "GitHub holds the whole job until a required reviewer approves it." + echo + echo "**Review this before approving:** approving publishes the tree of the" + echo "version commit \`${version_commit}\` — the commit that brought main to" + echo "${version}, i.e. what the Version Packages PR decided — and nothing that" + echo "landed after it, whatever main's head is by then." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + echo "publish-pending=false" >> "$GITHUB_OUTPUT" + case "$reason" in + version-commit-landed-earlier) + echo "::notice::main carries @objectstack/cli@${version}, not on npm, and its version commit ${version_commit} landed before this push (${range_detail}). Its publish prompt belongs to the push that carried it; this push queues none and evicts none. If no 'Publish ${version} to npm' prompt is waiting, dispatch this workflow (the repair lane) to queue it on ${version_commit}." + ;; + range-unreadable) + echo "::warning::main carries @objectstack/cli@${version}, not on npm, and this push's range cannot be read (${range_detail}), so it queues no publish rather than guess whether it carried the version commit ${version_commit}. If no 'Publish ${version} to npm' prompt is waiting, dispatch this workflow (the repair lane) to queue it." + ;; + *) + echo "::error::the predicate answered '${reason}' for an unpublished version; this step has no branch for it. Refusing to guess." + exit 1 + ;; + esac { - echo "## Release ${version} is waiting for your approval" + echo "## Release ${version} is not queued by this push" echo echo "main (\`${SHA}\`) carries **@objectstack/cli@${version}**, which is not on npm." - echo - echo "The **Publish ${version} to npm** job below is held at the \`release\`" - echo "environment gate. Nothing has been checked out, built or published —" - echo "GitHub holds the whole job until a required reviewer approves it." - echo - echo "**Review this before approving:** the version above is read from the" - echo "object database at \`${SHA}\`, so it is what main actually carries." - echo "Approving publishes exactly it." + echo "Its version commit is \`${version_commit}\`; ${range_detail}." + echo "Only the push that carries the version commit queues its publish." } >> "$GITHUB_STEP_SUMMARY" exit 0 fi @@ -927,6 +1016,68 @@ jobs: bash scripts/release-spec-changes.sh --prepare bash scripts/release-spec-changes.sh --attach + # ══════════════════════════════════════════════════════════════════════════ + # PUSH LANE 3 — stale approval prompts. Cancels; never publishes, never + # approves, never touches a job that has started. + # ══════════════════════════════════════════════════════════════════════════ + stale-prompts: + name: Cancel publish prompts for versions already on npm + # ADR-0125 D1 as amended 2026-09-29: a prompt for a version that is already + # published does not stay waiting. + # + # WHY IT MUST BE CANCELLED, NOT MERELY REPORTED — measured on the runs, not + # assumed: the publish job that holds `release-publish-` waits at the + # `release` environment WHILE HOLDING the group, and every later publish + # job pends behind it. So a prompt left waiting after its version shipped + # is not just a lookalike, it HIDES the next real one: 17.4.0's run + # 34308599522 waited 20 days, was the only visible prompt when 17.5.0 was + # queued, and was approved by mistake; 17.5.0 then left run 36539819278 + # waiting at 08:11Z, after cli@17.5.0 reached npm at 07:58:57Z. + # + # What is cancelled, exactly (scripts/release-pending-publish.mjs `sweep`, + # pinned by its --self-test): a WAITING run of this workflow, not this run, + # started by `push`, whose `Publish VERSION to npm (awaiting approval)` job + # is waiting at the `release` environment, with VERSION present on npm. + # - A dispatch run is never touched: it is a human's own act, and the D4 + # `force` repair legitimately waits for a version whose CLI is on npm. + # - A job GitHub holds at an environment has run no step, so this never + # interrupts a publish; the one it cancels could publish nothing — and + # if an approval lands in the second before the cancel, `publish`'s own + # guard refuses a push-lane release that is already on npm. + # Rejecting instead was measured and is not available: the review endpoint + # answers only a REQUIRED REVIEWER of the environment, which the workflow + # token is not (and must not become). + # + # Same two events as `release-integrity`, for the same reason: bookkeeping + # events get no release machinery. Not `needs`-ed by `publish` and needing + # nothing itself: a failed read here must be loud (a red job on main) but + # must never be able to hold up a release, and the order does not matter — + # a publish job queued behind a stale holder takes the group the moment + # the holder is cancelled. + if: >- + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && !inputs.refresh_version_pr) + runs-on: ubuntu-latest + permissions: + # `actions: write` is the cancel (POST .../runs/{id}/cancel); it also + # covers the three reads (waiting runs, their jobs, their pending + # deployments). `contents: read` is the checkout of the script. + actions: write + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Cancel waiting publish prompts whose version is already on npm + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/release-pending-publish.mjs sweep --workflow release.yml + # ══════════════════════════════════════════════════════════════════════════ # HUMAN LANE — the ONLY job in this repository that publishes. # ══════════════════════════════════════════════════════════════════════════ @@ -958,8 +1109,10 @@ jobs: # trigger to `workflow_dispatch` in the SAME change; do not leave this # running. environment: release - # `publish-pending` is the whole trigger predicate: true exactly when main's - # version is absent from npm. Unset (an audit that died before deciding) + # `publish-pending` is the whole trigger predicate: on a push, true exactly + # when that push carries the version commit and the version is absent from + # npm; on the repair dispatch, when the version is absent from npm (ADR-0125 + # D1 as amended 2026-09-29). Unset (an audit that died before deciding) # compares false, so this fails CLOSED. `force` is dispatch-only and exists # for the partial-publish repair D4 describes. # @@ -985,6 +1138,17 @@ jobs: runs-on: ubuntu-latest # One publish at a time per ref, and never cancelled — a run cancelled # mid-`changeset publish` is the state that leaves a fixed group half on npm. + # + # ⚠️ ADR-0125 D5's eviction applies to THIS group too, and did, until the + # 2026-09-29 amendment of D1. Measured on the runs: the job holding the + # group waits at the `release` environment WHILE HOLDING it; every later + # publish job pends behind it and evicts the pending one before it. While + # the predicate was "absent from npm", every landing queued one, so the + # prompt moved under the maintainer and whichever was pending last shipped + # ITS head. Now only the push carrying the version commit queues a publish, + # so the group holds one job per version and nothing evicts it; and a job + # left holding the group for a version already on npm is cancelled by + # `stale-prompts` above, never by `cancel-in-progress`. concurrency: group: release-publish-${{ github.ref }} cancel-in-progress: false @@ -994,8 +1158,21 @@ jobs: published: ${{ steps.publish.outputs.published }} cli-version: ${{ steps.guards.outputs.version }} steps: + # THE VERSION COMMIT, not `github.sha` (ADR-0125 D1 as amended + # 2026-09-29). `github.sha` is the head of whichever push queued this run; + # the version commit is the landing that brought main to the version the + # approval screen names — the tree the Version Packages PR decided to + # release. On 17.5.0 the two were eight PRs apart, one of them breaking. + # + # `fetch-depth: 0` is load-bearing for the guard below: "the version + # commit is on main" is an ancestry test against the freshly fetched + # `origin/main`, and on a shallow clone a non-ancestor answer is not + # evidence of anything. - name: Checkout repository uses: actions/checkout@v7 + with: + ref: ${{ needs.release-integrity.outputs.version-commit }} + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v7 @@ -1013,33 +1190,71 @@ jobs: # runs `changeset version`; this job publishes what the ref carries, or it # fails. # ────────────────────────────────────────────────────────────────────── - - name: Guard the approved release (branch, and the tree matches the commit) + - name: Guard the approved release (branch, version commit, and the tree matches it) id: guards env: # What release-integrity read out of the object database at # github.sha, and what the approval screen named. Read through env, # never interpolated into the shell. AUDITED: ${{ needs.release-integrity.outputs.cli-version }} + # The commit the checkout above was asked for. Empty would make + # `ref:` fall back to github.sha — the very defect — so it is refused. + VERSION_COMMIT: ${{ needs.release-integrity.outputs.version-commit }} + # 'true' only on the dispatch-only D4 repair, whose whole point is a + # version whose CLI is already on npm. + FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.force }} run: | if [ "${GITHUB_REF}" != "refs/heads/main" ]; then echo "::error::the publish lane may only run on main (got ${GITHUB_REF}). Publishing from any other ref would tag and ship code that never landed." exit 1 fi - # R2's tripwire, kept (ADR-0125 D1). The typed version is gone, so - # this is now the ONLY thing standing between an approval and a - # publish of something main does not carry. It is the exact assertion - # the 2026-08-03 recover-publish step lacked when it shipped rc.3 off - # a re-versioned workspace: read the version from the OBJECT DATABASE - # at github.sha and refuse if the checked-out tree disagrees. - committed=$(git show "${GITHUB_SHA}:packages/cli/package.json" | jq -r '.version') + # ── the version commit (ADR-0125 D1 as amended 2026-09-29) ──────── + # Every assertion here fails closed: the checkout must be the commit + # the audit selected, that commit must already be on main, and it must + # be the commit that CHANGED the version — not merely one carrying it. + if ! printf '%s' "$VERSION_COMMIT" | grep -Eqx '[0-9a-f]{40}'; then + echo "::error::release-integrity named no version commit ('${VERSION_COMMIT}'). An empty ref would check out github.sha — the head of whichever push queued this run — so refusing." + exit 1 + fi + head=$(git rev-parse HEAD) + if [ "$head" != "$VERSION_COMMIT" ]; then + echo "::error::the workspace is at ${head}, not the version commit ${VERSION_COMMIT}. Refusing to publish a tree nobody selected." + exit 1 + fi + if [ "$(git rev-parse --is-shallow-repository)" != 'false' ]; then + echo "::error::shallow clone: an ancestry answer here would not be evidence. The checkout above must keep fetch-depth: 0." + exit 1 + fi + if ! git merge-base --is-ancestor "$VERSION_COMMIT" refs/remotes/origin/main; then + echo "::error::the version commit ${VERSION_COMMIT} is not on main as fetched just now. Publishing it would tag and ship code that never landed." + exit 1 + fi + if ! git merge-base --is-ancestor "$VERSION_COMMIT" "$GITHUB_SHA"; then + echo "::error::the version commit ${VERSION_COMMIT} is not an ancestor of ${GITHUB_SHA}, the head it was selected from." + exit 1 + fi + + # R2's tripwire, kept (ADR-0125 D1), now against the version commit. + # The typed version is gone, so this is the thing standing between an + # approval and a publish of something main does not carry. It is the + # exact assertion the 2026-08-03 recover-publish step lacked when it + # shipped rc.3 off a re-versioned workspace: read the version from the + # OBJECT DATABASE at the commit being published and refuse if the + # checked-out tree disagrees. + committed=$(git show "${VERSION_COMMIT}:packages/cli/package.json" | jq -r '.version') declared=$(jq -r '.version' packages/cli/package.json) if [ -z "$committed" ] || [ "$committed" = "null" ]; then - echo "::error::could not read @objectstack/cli version at ${GITHUB_SHA}" + echo "::error::could not read @objectstack/cli version at ${VERSION_COMMIT}" exit 1 fi if [ "$declared" != "$committed" ]; then - echo "::error::workspace carries @objectstack/cli@${declared} but ${GITHUB_SHA} carries ${committed} — something re-versioned this workspace (#6170). Refusing to publish a version main does not have." + echo "::error::workspace carries @objectstack/cli@${declared} but ${VERSION_COMMIT} carries ${committed} — something re-versioned this workspace (#6170). Refusing to publish a version main does not have." + exit 1 + fi + parent=$(git show "${VERSION_COMMIT}^1:packages/cli/package.json" 2>/dev/null | jq -r '.version' || true) + if [ "$parent" = "$committed" ]; then + echo "::error::${VERSION_COMMIT} carries @objectstack/cli@${committed} but so does its parent, so it is not the commit that changed the version. Refusing to publish from anything but the version commit." exit 1 fi @@ -1047,17 +1262,44 @@ jobs: # moved since, the human approved a different release than the one # about to ship — refuse rather than ship the surprise. if [ -n "$AUDITED" ] && [ "$AUDITED" != "$committed" ]; then - echo "::error::the release approved was @objectstack/cli@${AUDITED} but ${GITHUB_SHA} carries ${committed}. Refusing to publish a version nobody approved." + echo "::error::the release approved was @objectstack/cli@${AUDITED} but ${VERSION_COMMIT} carries ${committed}. Refusing to publish a version nobody approved." exit 1 fi + # ── a stale prompt, approved anyway ─────────────────────────────── + # A prompt can outlive its release: queued while the version was + # absent, approved after some other run shipped it. The 17.4.0 prompt + # of run 34308599522 was approved that way, in place of 17.5.0's. + # Approving one must publish nothing and say so. `force` is exempt — + # a partial publish leaves the CLI canary on npm by definition. An + # unreadable registry is not evidence of staleness: `changeset + # publish` skips an already-published version by itself. + # + # The same three-way reading as classifyNpmView() in + # scripts/release-pending-publish.mjs, INLINED on purpose: this guard + # runs before the checked-out tree is trusted, so it executes nothing + # from that tree — git, jq and npm only, as the rest of it does. (The + # version commit's tree may also predate the script.) + if [ "$FORCE" != 'true' ]; then + if npm_out=$(npm view "@objectstack/cli@${committed}" version 2>"${RUNNER_TEMP}/npm-view.err"); then + if [ "$(printf '%s' "$npm_out" | tr -d '[:space:]')" = "$committed" ]; then + echo "::error::@objectstack/cli@${committed} is already on npm, so this approval prompt was stale when it was approved — nothing is published from it. If a newer release is due, its own 'Publish VERSION to npm' prompt is the one to approve; if ${committed} is only partly published, dispatch this workflow with 'force' (ADR-0125 D4)." + exit 1 + fi + elif ! grep -q 'E404' "${RUNNER_TEMP}/npm-view.err"; then + cat "${RUNNER_TEMP}/npm-view.err" + echo "::warning::npm could not be read to confirm @objectstack/cli@${committed} is still unpublished; continuing, since changeset publish skips any version the registry already has." + fi + fi + echo "version=$committed" >> "$GITHUB_OUTPUT" - echo "Publishing @objectstack/cli@${committed} from ${GITHUB_SHA} (approved on the 'release' environment)." + echo "Publishing @objectstack/cli@${committed} from its version commit ${VERSION_COMMIT} (approved on the 'release' environment; this run was queued by ${GITHUB_SHA})." { echo "## Publishing ${committed}" echo - echo "- ref: \`${GITHUB_REF}\` @ \`${GITHUB_SHA}\`" - echo "- started by: \`${GITHUB_ACTOR}\` (\`${GITHUB_EVENT_NAME}\`)" + echo "- version commit: \`${VERSION_COMMIT}\` — the tree that is built, tagged and published" + echo "- queued by: \`${GITHUB_REF}\` @ \`${GITHUB_SHA}\` (\`${GITHUB_EVENT_NAME}\`)" + echo "- started by: \`${GITHUB_ACTOR}\`" echo "- authorised by: the \`release\` environment approval on this run —" echo " see the run's deployment history for the reviewer and timestamp" } >> "$GITHUB_STEP_SUMMARY" @@ -1082,12 +1324,22 @@ jobs: # push and warmed the cache for everyone; it now runs only when a human # publishes. lint.yml's "Save Turbo cache (main only)" is the seeder. The # key is namespaced by `github.job`, which changed from `release` to - # `publish` — the first release after this PR builds cold once. + # `publish` — the first release after this PR builds cold once. Keyed on + # the VERSION COMMIT, the tree actually built, not on `github.sha` + # (ADR-0125 D1 as amended 2026-09-29); turbo re-hashes its inputs either + # way, so the key names the entry and decides nothing about correctness. + # + # ⚠️ From the checkout on, the steps are this FILE at `github.sha` while + # the scripts they run are the VERSION COMMIT's. On the version push the + # two are the same commit or a merge-queue batch apart; on a repair + # dispatch they can be further apart. A step naming a script the version + # commit predates fails on the missing file, which is the right failure: + # a release is built by the version commit's own tooling. - name: Setup Turbo cache uses: actions/cache@v6 with: path: .turbo/cache - key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ needs.release-integrity.outputs.version-commit }} restore-keys: | ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}- ${{ runner.os }}-turbo-${{ github.job }}- diff --git a/docs/adr/0125-release-approval-gate-replaces-the-typed-version.md b/docs/adr/0125-release-approval-gate-replaces-the-typed-version.md index a47c75e7d49..76f176486d3 100644 --- a/docs/adr/0125-release-approval-gate-replaces-the-typed-version.md +++ b/docs/adr/0125-release-approval-gate-replaces-the-typed-version.md @@ -1,6 +1,6 @@ # ADR-0125: The human act that authorises a release is the environment approval, not a typed version string -**Status**: Accepted (2026-08-20) — accepted by the merge that landed it on `main` ([#10150](https://github.com/objectstack-ai/objectstack/pull/10150), commit `81d1fa11d`), which is itself the acceptance act for a governed surface (Prime Directive #14). Implementation shipped in the same PR: `.github/workflows/release.yml`. +**Status**: Accepted (2026-08-20) — accepted by the merge that landed it on `main` ([#10150](https://github.com/objectstack-ai/objectstack/pull/10150), commit `81d1fa11d`), which is itself the acceptance act for a governed surface (Prime Directive #14). Implementation shipped in the same PR: `.github/workflows/release.yml`. · **Amended** (2026-09-29, [#20613](https://github.com/objectstack-ai/objectstack/issues/20613) — accepted by the merge that lands it. **D1's premise did not hold**: "not on npm" stayed true from the version-PR merge until the publish *finished*, so every landing in between queued its own publish of its own head, and 17.5.0 shipped eight PRs past its version commit. The predicate is now "this push carries the version commit", the publish job builds that commit, and a push-lane prompt for a version already on npm is cancelled. The decision itself — merging the Version Packages PR decides, approving `release` authorises — is unchanged; see **"Amendment (2026-09-29, #20613): the push that carries the version commit, and only that commit"** at the end.) **Deciders**: ObjectStack Protocol Architects (maintainer ruling, 2026-08-20, on the back of the [#10146](https://github.com/objectstack-ai/objectstack/issues/10146) release failure) **Builds on**: the 2026-08-07 maintainer ruling recorded in **AGENTS.md Prime Directive #15** (「版本发布必须是人工的」) and its implementation in [#6170](https://github.com/objectstack-ai/objectstack/issues/6170) (the two-lane split of `release.yml`) **Supersedes**: nothing. It **re-implements** Prime Directive #15's requirement; the requirement itself is untouched and is quoted again below so no later reader has to reconstruct it. @@ -58,6 +58,8 @@ Worth stating because #10146 turned on it: the version in the failing run's log The predicate is computed the hardened way #6170 established and this record does not relax: the version is read from the **object database at `github.sha`**, never off disk, with the tripwire that fails the run if the checked-out workspace disagrees. That tripwire is the assertion the 2026-08-03 `recover-publish` step lacked when it shipped rc.3 off a re-versioned tree. +> **⚠️ Amended 2026-09-29 ([#20613](https://github.com/objectstack-ai/objectstack/issues/20613)).** "True only just after a version PR merges, and false on all ~18 other daily landings" was wrong: the predicate stays true until the publish finishes, so every landing in that window queued a publish of its own `github.sha`. The predicate and the commit the publish job builds are replaced by D1′ and D1″ in the amendment at the end; the text above is kept as the record of what was decided. + ### D2 — The human act is approving the `release` environment; the approval screen names the version `environment: release` gates the publish job. GitHub holds the **entire job** — no checkout, no build, no `changeset publish` — until a required reviewer approves. The job's `name:` is computed from the audited version, so the approval screen reads @@ -86,6 +88,8 @@ Why it must change: a job waiting on an approval keeps its run **in progress**. Per-job groups restore the property the old comment was protecting — "the two lanes can never displace each other" — now that the event name no longer carries it. +> **⚠️ Amended 2026-09-29 ([#20613](https://github.com/objectstack-ai/objectstack/issues/20613)).** The same eviction applies to the publish job's own group, and D5 did not consider it: the job holding the group waits at the environment while holding it, and every later publish job pends behind it, evicting the one before. See the amendment at the end. + ### D6 — What an AI seat may still not do is unchanged, and one item is added Prime Directive #15's prohibitions stand as written. This record adds the new act to the list: ⛔ **approving the `release` environment deployment**. It is now the release act, and it is reserved exactly as `workflow_dispatch` was. @@ -107,3 +111,58 @@ Note the shape of what D1 does to the existing prohibition on merging the Versio **Publish automatically on the version-PR merge, no approval.** Rejected without discussion: it is the rc.3 / rc.4 incident by design rather than by accident, and it contradicts the 2026-08-07 ruling rather than re-implementing it. **Split the build out of the gated job so the approval comes last.** Would let CI build while the maintainer decides, cutting the wall-clock after approval. Rejected for now: it puts a full release build *before* the human act, which is the shape that trained everyone to treat a running release job as normal. Approval-first means nothing at all moves until a human moves it, and ~10 minutes of build after the click is a price worth paying for that. + +## Amendment (2026-09-29, #20613): the push that carries the version commit, and only that commit + +**Status of this amendment**: proposed in the PR that implements it, [#20613](https://github.com/objectstack-ai/objectstack/issues/20613); accepted by the merge that lands it, which is the acceptance act for a governed surface (Prime Directive #14). **Change**: D1's predicate, and the commit the publish job builds. D2's approval act, D3's condition, D4's repair lane and D6's prohibitions stand as written. This is a correction of the record, not a reversal: the decision — merging the Version Packages PR decides, approving `release` authorises — is unchanged. What was false is the premise that made D1's predicate implement it. + +### What was wrong + +D1 said "main's `@objectstack/cli` version is not on npm" is "true only just after a version PR merges, and false on all ~18 other daily landings". It is true from the version-PR merge until the publish **finishes**: through the approval wait and the ~30-minute publish after it. Every landing in that window ran `release-integrity`, found the version absent, and queued its own `Publish to npm` job, and every one of those jobs checked out `github.sha` of **its own** push. + +Measured on the 17.5.0 release (2026-09-29, times UTC, read from the Actions API): + +| Release run | pushed head | its publish job | +|:--|:--|:--| +| 36533007563 | `3a89d459` — a merge-queue batch carrying the version commit `8c87d26a` and four PRs | queued 06:48:38, evicted 06:52:43 | +| 36533376091 | `7001918e` | queued 06:52:42, evicted 06:58:41 | +| 36533921490 | `c96beb27` | queued 06:58:40, evicted 07:12:45 | +| 36535264066 | `ba4648da` | queued 07:12:44, evicted 07:21:13 | +| 36536081716 | `0f6dcac5` | queued 07:21:13, approved and started 07:40:26 — **shipped 17.5.0** | + +`git log --first-parent 8c87d26a..0f6dcac5` lists eight PRs, one of them `feat(spec)!`. They ship inside 17.5.0 while their changesets stay unconsumed in `.changeset/`, so the 17.5.0 CHANGELOG omits them and the next version will announce them as new. The Version Packages PR decided one tree and the approval shipped another. D2's "the reviewer confirms a version they are *shown*" could not catch it: the version on the screen was right, the tree behind it was not. + +### The concurrency mechanics, measured rather than assumed + +D5 reasoned about eviction for the bookkeeping lane. The publish job's own group, `release-publish-`, behaves like this on the runs above: + +- the job that takes the group then waits at the `release` environment **while holding it**; +- every later publish job pends behind it, and each new one evicts the pending one before it — at the second it is created. + +Two consequences. While every landing queued a publish, the prompt moved under the maintainer, and whichever job was pending last is the one that shipped. And a prompt left waiting after its version shipped **holds the group and hides the next real prompt**: the 17.4.0 prompt of run 34308599522 waited from 2026-09-09 to 2026-09-29, was the only visible prompt while 17.5.0's jobs pended behind it, and was approved by mistake in place of 17.5.0 (it was cancelled during Build at 07:39:36 and published nothing; 36536081716 then took the group and was approved at 07:40:26). 17.5.0 left another of the same shape: run 36539819278's `Publish 17.5.0` job took the group at 08:11:15 and began waiting, after `@objectstack/cli@17.5.0` had reached npm at 07:58:57. + +### What changes + +**D1′ — the predicate.** The *version commit* is the newest commit on main's first-parent chain at which `packages/cli/package.json`'s version differs from its first parent's: the landing that brought main to the version it carries. On a push, a publish is pending exactly when **this push carries the version commit** — it is not an ancestor of `github.event.before` — **and** that version is absent from npm. A later landing carries no version commit, so it queues nothing and evicts nothing. A push whose range cannot be read (a branch creation, a force-push) queues nothing and says why; the repair dispatch covers it. The repair dispatch (D4) keeps "absent from npm" alone, because it is one human act and not one per landing. + +**D1″ — the commit that is built.** The publish job checks out the version commit, never `github.sha`. Its guard asserts that the checkout is the selected commit, that the commit is on `main` as fetched after the approval, and that its parent carries a different version; the #6170 tripwire (object database against workspace) now runs against the version commit. The tags `changeset publish` creates therefore point at it. + +**D2, re-grounded.** The approval screen still names the version. What the approval *does* is now the thing D2 promised: it publishes the tree the Version Packages PR decided — the version commit — and nothing that landed after it, however long the approval takes. + +**D5, extended to the publish group.** With one publish job per version the group holds one job and nothing evicts it. A **push-lane** prompt still waiting for a version that is already on npm is **cancelled** by a push-lane job holding `actions: write` — never by `cancel-in-progress`, and never a job that has started: GitHub holds the whole job at the environment, so a waiting job has run no step. Dispatch runs are never cancelled: a dispatch is a human's own act, and the D4 `force` repair waits, by design, for a version whose CLI is already on npm. Rejecting instead was considered and is not available to a workflow: the pending-deployment review answers only a required reviewer of the environment, which the workflow token is not and must not become. As a second line, a prompt approved after its version shipped refuses in its own guard and publishes nothing. + +### What does not change + +- Merging the Version Packages PR is the decision to release, and approving `release` is the authorisation (D2). Nothing in this amendment approves anything, and D6 stands. +- D3: this record is void if the `release` environment has no required reviewers. +- D4: the repair lane and its `force` input. +- Not decided here: what to do about 17.5.0 as published — annotating its notes, or a follow-up release that consumes the eight changesets. That is the maintainer's call. + +### Implementation + +`.github/workflows/release.yml` — `release-integrity` names the `version-commit` and computes the amended `publish-pending`; `publish` checks out the version commit and guards it; the new `stale-prompts` job cancels stale push-lane prompts — and `scripts/release-pending-publish.mjs`, which holds all three decisions. Its `--self-test`, run by `lint.yml`, builds throwaway repositories for a version commit followed by two landings, a merge-queue batch, a published version, a dependency-only edit of the CLI manifest, a non-linear landing, a force-push and a shallow clone, and asserts the selected sha. Replayed against main's real 2026-09-29 pushes with npm answering "absent", it selects `8c87d26a` on the batch push `3a89d459` and queues nothing on the five pushes after it. + +### Known edges, stated + +- The version push's own audit can still be evicted while pending in `release-integrity`'s group — the residual race `release.yml` documents. The next landing no longer re-queues the publish by accident (that accident was the defect); the repair dispatch does, on the same version commit, and every later push's audit says in a notice that main's version is unpublished and was not queued there. +- From the checkout on, the publish job's steps are the workflow file at `github.sha` while the scripts they run are the version commit's. On the version push they are the same commit or one merge-queue batch apart; a repair dispatch can put them further apart. diff --git a/scripts/release-pending-publish.mjs b/scripts/release-pending-publish.mjs new file mode 100644 index 00000000000..5d44511831a --- /dev/null +++ b/scripts/release-pending-publish.mjs @@ -0,0 +1,752 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * release-pending-publish -- WHICH commit a release publishes, WHICH push queues + * its approval prompt, and WHICH waiting prompts are no longer a release + * (ADR-0125 D1, as amended 2026-09-29). + * + * node scripts/release-pending-publish.mjs select --event push --head SHA --before SHA + * node scripts/release-pending-publish.mjs select --event workflow_dispatch --head SHA + * node scripts/release-pending-publish.mjs npm-state VERSION + * node scripts/release-pending-publish.mjs sweep --workflow release.yml [--dry-run] + * node scripts/release-pending-publish.mjs --self-test + * + * ## The measured failure (#20613) + * + * ADR-0125 D1 gated the publish job on ONE predicate -- "main's + * `@objectstack/cli` version is not on npm" -- and said it is true only just + * after a Version Packages PR merges. It is true from that merge until the + * publish FINISHES, and main takes ~18 landings a working day, so on the 17.5.0 + * release every landing in between queued its own `Publish 17.5.0` job, and + * every one of them checked out `github.sha` of ITS push. Measured: + * + * version commit 8c87d26a (chore: version packages), landed inside the + * merge-queue batch pushed as 3a89d459 + * shipped from 0f6dcac5 (run 36536081716), eight landings later + * git log --first-parent 8c87d26a..0f6dcac5 8 PRs, one `feat(spec)!`, + * changesets unconsumed + * + * The concurrency group on the publish job made it worse, and the mechanics were + * read off the runs, not assumed: the job that holds `release-publish-` + * waits at the `release` environment WHILE HOLDING it, and every later publish + * job pends behind it, each one evicting the pending job before it. A prompt + * left waiting after its version shipped therefore holds the group and HIDES the + * next real one -- the 17.4.0 prompt of run 34308599522 waited 20 days, was the + * only visible prompt when 17.5.0 was queued, and was approved by mistake. + * 17.5.0 left one exactly like it: run 36539819278 started waiting at 08:11Z on + * 2026-09-29, after `@objectstack/cli@17.5.0` reached npm at 07:58:57Z. + * + * ## What each mode answers + * + * `select` -- THE predicate. The version commit is the newest commit on the + * head's FIRST-PARENT chain at which `packages/cli/package.json`'s version + * differs from its first parent's: the landing that brought main to the version + * it carries. It is computed on both release events, because the publish job + * checks out THAT commit on both. Whether a deployment is queued differs: + * + * push pending ⇔ the version commit is IN this push + * (not an ancestor of `before`) AND the version is not on + * npm. A later landing carries no version commit, so it + * queues nothing and evicts nothing. + * workflow_dispatch pending ⇔ the version is not on npm. A dispatch is a + * human act, not repeated per landing, and it is the repair + * lane for a version-push audit that never queued (D4). + * + * A push range that cannot be read -- `before` all zeros, `before` absent from + * the clone, `before` not an ancestor of the head (a force-push) -- answers + * NOT pending with a reason, never a guess: the repair dispatch covers it. + * A SHALLOW clone is refused outright: at the graft boundary the oldest fetched + * commit has no parent, reads as "the version changed here", and would be + * returned as the version commit -- a confident wrong answer. + * + * `npm-state` -- present / absent / unknown for one version, from `npm view`'s + * exit status and its `E404`. The caller decides what `unknown` means: the + * audit keeps its old reading (not on npm), the publish guard proceeds with a + * warning, because `changeset publish` skips an already-published version by + * itself and an unreachable registry cannot publish anything either. + * + * `sweep` -- the waiting-prompt half. Lists this workflow's runs that are + * `waiting`, and CANCELS a run only when ALL of these hold: + * + * - it is not the calling run; + * - its event is `push` (a dispatch is a human's own act, and the D4 `force` + * repair legitimately waits for a version whose CLI is already on npm -- + * the run object does not expose its inputs, so no dispatch is touched); + * - one of its jobs is `waiting` and named `Publish VERSION to npm (awaiting + * approval)` -- the approval screen ADR-0125 D2 defines; + * - its pending deployments include the `release` environment; + * - VERSION is present on npm. + * + * Such a job has run no step: GitHub holds the WHOLE job at the environment, so + * cancelling it never interrupts a publish. And if an approval lands in the + * second between the read and the cancel, the job's own guard refuses anyway -- + * a push-lane job whose version is already on npm stops before it builds. + * Permission: `actions: write` for the cancel (the reads need `actions: read`). + * Rejecting instead is not available to a workflow: the pending-deployments + * review endpoint answers only a REQUIRED REVIEWER of the environment, and the + * workflow token is not one -- nor should it be. + * + * `--dry-run` reports what `sweep` would cancel and refuses any non-GET request + * at the HTTP layer, so it is safe to point at the live repository. + * + * ## Why the pins live here, not in the workflow + * + * The production path runs a handful of times a year, on a runner, against a + * history nobody can rewind. `--self-test` builds throwaway repositories for the + * sequences that matter (a version commit then two landings, a merge-queue + * batch, a published version, a force-push, a shallow clone) and asserts the + * selected sha; it is wired in lint.yml as the only instrument on this logic. + */ + +import { spawnSync } from 'node:child_process'; +import { appendFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { isEntrypoint } from './invoked-as.mjs'; + +export const CLI_MANIFEST = 'packages/cli/package.json'; +export const CLI_PACKAGE = '@objectstack/cli'; + +/** The approval screen ADR-0125 D2 defines, as `release.yml`'s `publish` job names it. */ +export const PUBLISH_JOB_NAME = /^Publish (\S+) to npm \(awaiting approval\)$/; + +const ZERO_SHA = /^0{40}$/; +const FULL_SHA = /^[0-9a-f]{40}$/; +const VERSION_SHAPE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +// ───────────────────────────────────────────────────────────────────────────── +// git +// ───────────────────────────────────────────────────────────────────────────── + +function git(cwd, args) { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (r.error) throw r.error; + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; +} + +function gitOk(cwd, args) { + const r = git(cwd, args); + if (r.status !== 0) { + throw new Error(`git ${args.join(' ')} exited ${r.status}: ${r.stderr.trim()}`); + } + return r.stdout; +} + +/** `git merge-base --is-ancestor`: true / false, and anything else throws. */ +function isAncestor(cwd, ancestor, descendant) { + const r = git(cwd, ['merge-base', '--is-ancestor', ancestor, descendant]); + if (r.status === 0) return true; + if (r.status === 1) return false; + throw new Error(`git merge-base --is-ancestor ${ancestor} ${descendant} exited ${r.status}: ${r.stderr.trim()}`); +} + +/** The CLI version a commit carries, read from the object database; null when the file is absent. */ +export function versionAt(cwd, rev) { + const r = git(cwd, ['show', `${rev}:${CLI_MANIFEST}`]); + if (r.status !== 0) return null; + const version = JSON.parse(r.stdout).version; + if (typeof version !== 'string' || version === '') { + throw new Error(`${CLI_MANIFEST} at ${rev} carries no "version" string`); + } + return version; +} + +/** + * The version commit for `head`: the newest first-parent commit at which the + * CLI version differs from its first parent's. Throws on a shallow clone and on + * a history that never introduces the head's version. + */ +export function findVersionCommit({ cwd, head }) { + if (gitOk(cwd, ['rev-parse', '--is-shallow-repository']).trim() !== 'false') { + throw new Error( + 'refusing to select a version commit in a shallow clone: at the graft boundary the oldest fetched ' + + 'commit has no parent and would read as the commit that changed the version. Check out with fetch-depth: 0.', + ); + } + const headSha = gitOk(cwd, ['rev-parse', '--verify', `${head}^{commit}`]).trim(); + const version = versionAt(cwd, headSha); + if (version === null) throw new Error(`${headSha} carries no ${CLI_MANIFEST}`); + + const touching = gitOk(cwd, ['log', '--first-parent', '--format=%H', headSha, '--', CLI_MANIFEST]) + .split('\n') + .filter(Boolean); + for (const commit of touching) { + const here = versionAt(cwd, commit); + if (here !== version) { + throw new Error( + `first-parent walk from ${headSha} reached ${commit} carrying ${here} before finding where ${version} ` + + 'was introduced -- the walk is not reading the history it claims to.', + ); + } + const parent = git(cwd, ['rev-parse', '--verify', '--quiet', `${commit}^1`]); + const before = parent.status === 0 ? versionAt(cwd, parent.stdout.trim()) : null; + if (before !== version) return { version, versionCommit: commit, head: headSha }; + } + throw new Error(`no first-parent commit of ${headSha} introduces ${CLI_PACKAGE}@${version}`); +} + +/** + * Is the version commit part of THIS push? `in-push` / `earlier` / `unreadable`. + * Unreadable is a verdict with a reason, never an exception: a force-push or a + * branch creation is a real event on a real push, and the answer to it is + * "queue nothing, say why". + */ +export function rangeState({ cwd, versionCommit, before, head }) { + if (!before || ZERO_SHA.test(before)) { + return { state: 'unreadable', detail: `before is ${before ? 'the all-zero sha' : 'empty'}: the push has no prior tip to measure from` }; + } + if (git(cwd, ['cat-file', '-e', `${before}^{commit}`]).status !== 0) { + return { state: 'unreadable', detail: `before ${before} is not in this clone (a force-push dropped it from every ref?)` }; + } + if (!isAncestor(cwd, before, head)) { + return { state: 'unreadable', detail: `before ${before} is not an ancestor of ${head}: the push rewrote the branch` }; + } + return isAncestor(cwd, versionCommit, before) + ? { state: 'earlier', detail: `${versionCommit} was already on the branch at ${before}` } + : { state: 'in-push', detail: `${versionCommit} landed in ${before}..${head}` }; +} + +/** The whole predicate over already-measured inputs -- pure, so every row of it is pinned. */ +export function decidePending({ event, range, npm }) { + if (npm === 'present') return { pending: false, reason: 'published' }; + if (event === 'workflow_dispatch') return { pending: true, reason: 'dispatch-unpublished' }; + if (event !== 'push') throw new Error(`no release predicate for event "${event}"`); + if (range.state === 'in-push') return { pending: true, reason: 'carries-version-commit' }; + if (range.state === 'earlier') return { pending: false, reason: 'version-commit-landed-earlier' }; + if (range.state === 'unreadable') return { pending: false, reason: 'range-unreadable' }; + throw new Error(`unknown range state "${range.state}"`); +} + +export function select({ cwd, event, head, before, npmStateOf }) { + const found = findVersionCommit({ cwd, head }); + const npm = npmStateOf(found.version); + const range = + event === 'push' + ? rangeState({ cwd, versionCommit: found.versionCommit, before, head: found.head }) + : { state: 'not-a-push', detail: `event ${event} has no push range` }; + return { ...found, event, before: before || null, npm, range, ...decidePending({ event, range, npm }) }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// npm +// ───────────────────────────────────────────────────────────────────────────── + +/** Classify one `npm view` result. Exported for the self-test; `npmState` is the live caller. */ +export function classifyNpmView(version, { status, stdout, stderr }) { + if (status === 0) { + const printed = String(stdout).trim(); + if (printed === version) return 'present'; + if (printed === '') return 'absent'; + return 'unknown'; + } + return /\bE404\b/.test(String(stderr)) ? 'absent' : 'unknown'; +} + +export function npmState(version, run = spawnSync) { + if (!VERSION_SHAPE.test(version)) throw new Error(`not a version: ${JSON.stringify(version)}`); + const r = run('npm', ['view', `${CLI_PACKAGE}@${version}`, 'version'], { encoding: 'utf8' }); + if (r.error) return 'unknown'; + return classifyNpmView(version, r); +} + +// ───────────────────────────────────────────────────────────────────────────── +// The waiting-prompt sweep +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Judge waiting runs -- pure. Each run is `{ id, event, status, jobs: [{ name, + * status }], environments: [name] }`; every run lands in exactly one of + * `cancel` / `keep` / `untouched`, with its reason. + */ +export function judgeWaitingRuns({ runs, currentRunId, npmStateOf }) { + const cancel = []; + const keep = []; + const untouched = []; + for (const run of runs) { + const id = String(run.id); + if (id === String(currentRunId)) { + untouched.push({ id, reason: 'the calling run' }); + continue; + } + // ⛔ The mid-flight guard. A run whose publish job was approved is + // `in_progress`, and cancelling THAT is the half-published fixed group the + // publish job's concurrency comment forbids. Only a job GitHub is still + // holding at the environment -- no step run -- is ever a candidate. + if (run.status !== 'waiting') { + untouched.push({ id, reason: `run status ${run.status}, not waiting` }); + continue; + } + const prompts = run.jobs.filter((j) => j.status === 'waiting' && PUBLISH_JOB_NAME.test(j.name)); + if (prompts.length === 0) { + untouched.push({ id, reason: 'no publish job waiting at an environment' }); + continue; + } + if (!run.environments.includes('release')) { + untouched.push({ id, reason: 'not waiting on the release environment' }); + continue; + } + const versions = [...new Set(prompts.map((j) => PUBLISH_JOB_NAME.exec(j.name)[1]))]; + if (versions.length !== 1) { + untouched.push({ id, reason: `${versions.length} different publish prompts in one run; not judged` }); + continue; + } + const version = versions[0]; + const npm = npmStateOf(version); + const entry = { id, event: run.event, version, npm }; + if (run.event !== 'push') { + untouched.push({ + ...entry, + reason: 'a dispatch is a human act, and a force repair waits for a version whose CLI is on npm by design', + }); + continue; + } + if (npm === 'present') { + cancel.push({ ...entry, reason: `${CLI_PACKAGE}@${version} is already on npm` }); + } else { + keep.push({ + ...entry, + reason: npm === 'absent' ? 'a live release waiting for its approval' : 'npm could not be read, so nothing is judged stale', + }); + } + } + return { cancel, keep, untouched }; +} + +function makeHttp({ apiUrl, token, dryRun }) { + return async (method, path) => { + // Structural, not a flag checked later: a dry run cannot reach a write. + if (dryRun && method !== 'GET') throw new Error(`--dry-run refuses ${method} ${path}`); + const res = await fetch(`${apiUrl}${path}`, { + method, + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'x-github-api-version': '2022-11-28', + 'user-agent': 'objectstack-release-pending-publish', + }, + }); + const text = await res.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + return { status: res.status, body }; + }; +} + +function expectOk(res, what) { + if (res.status !== 200) { + throw new Error(`${what} answered HTTP ${res.status}: ${JSON.stringify(res.body).slice(0, 300)}`); + } + return res.body; +} + +/** Read every waiting run of one workflow into `judgeWaitingRuns`' shape. `http` is injectable. */ +export async function collectWaitingRuns({ http, repo, workflow }) { + const list = expectOk( + await http('GET', `/repos/${repo}/actions/workflows/${encodeURIComponent(workflow)}/runs?status=waiting&per_page=100`), + `listing ${workflow}'s waiting runs`, + ); + const runs = []; + for (const r of list.workflow_runs) { + const jobs = expectOk( + await http('GET', `/repos/${repo}/actions/runs/${r.id}/jobs?filter=latest&per_page=100`), + `listing run ${r.id}'s jobs`, + ); + const pending = expectOk( + await http('GET', `/repos/${repo}/actions/runs/${r.id}/pending_deployments`), + `reading run ${r.id}'s pending deployments`, + ); + runs.push({ + id: r.id, + event: r.event, + status: r.status, + headSha: r.head_sha, + jobs: jobs.jobs.map((j) => ({ name: j.name, status: j.status })), + environments: pending.map((p) => p.environment && p.environment.name).filter(Boolean), + }); + } + return { runs, total: list.total_count }; +} + +function requireEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required`); + return value; +} + +async function sweep({ workflow, dryRun }) { + const repo = requireEnv('GITHUB_REPOSITORY'); + const token = requireEnv('GITHUB_TOKEN'); + const apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com'; + const currentRunId = process.env.GITHUB_RUN_ID || ''; + const http = makeHttp({ apiUrl, token, dryRun }); + + const { runs, total } = await collectWaitingRuns({ http, repo, workflow }); + if (total > runs.length) { + console.log(`::warning::${total} waiting runs, only the first ${runs.length} judged.`); + } + const verdict = judgeWaitingRuns({ runs, currentRunId, npmStateOf: (v) => npmState(v) }); + + const lines = [`### Waiting approval prompts (${runs.length} waiting run(s) of ${workflow})`, '']; + const failures = []; + for (const c of verdict.cancel) { + const url = `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${repo}/actions/runs/${c.id}`; + if (dryRun) { + console.log(`would cancel run ${c.id}: Publish ${c.version} -- ${c.reason}`); + lines.push(`- would cancel [${c.id}](${url}): \`Publish ${c.version}\`, ${c.reason}`); + continue; + } + const res = await http('POST', `/repos/${repo}/actions/runs/${c.id}/cancel`); + if (res.status === 202) { + console.log(`::warning::cancelled run ${c.id}: its "Publish ${c.version} to npm" prompt was still waiting, and ${c.reason}. Approving it would have published nothing, and it held the release-publish group ahead of any real prompt.`); + lines.push(`- cancelled [${c.id}](${url}): \`Publish ${c.version}\`, ${c.reason}`); + } else if (res.status === 409) { + console.log(`run ${c.id} finished before it could be cancelled (HTTP 409) -- nothing waiting there any more.`); + lines.push(`- [${c.id}](${url}) finished before the cancel landed`); + } else { + failures.push(`cancel of run ${c.id} answered HTTP ${res.status}`); + } + } + for (const k of verdict.keep) { + if (k.npm === 'unknown') console.log(`::warning::run ${k.id} waits to publish ${k.version}, and npm could not be read to judge it.`); + lines.push(`- kept ${k.id}: \`Publish ${k.version}\`, ${k.reason}`); + } + for (const u of verdict.untouched) { + if (u.version && u.npm === 'present') { + console.log(`::warning::run ${u.id} (${u.event}) waits to publish ${u.version}, which is already on npm. Not cancelled: ${u.reason}.`); + } + lines.push(`- untouched ${u.id}: ${u.reason}`); + } + if (runs.length === 0) lines.push('- none waiting'); + console.log(lines.join('\n')); + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${lines.join('\n')}\n`); + + if (failures.length > 0) { + for (const f of failures) console.log(`::error::${f}`); + process.exit(1); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Self-test +// ───────────────────────────────────────────────────────────────────────────── + +// Set as the self-test's LAST statement, after its success line prints, and +// read at the dispatch: a `return` above the verdict prints nothing and would +// otherwise exit 0 -- a self-test that never finished, reported as one that +// passed. +let selfTestReachedVerdict = false; + +// The battery roster, pinned by NAME with a per-battery floor (the +// check-agent-model-declared.mjs shape). A deleted or renamed battery names +// itself in the refusal; a pinned total would not. +const SELF_TEST_BATTERIES = Object.freeze({ + 'a version commit then two landings -> ONE pending publish, pinned to the version commit': 4, + 'a merge-queue batch carrying the version commit -> the version commit, not the head': 2, + 'a published version -> nothing pending on either release event': 2, + 'a dependency-only edit of the CLI manifest -> still the version commit': 1, + 'a non-linear landing -> the merge that brought the version onto the branch': 2, + 'an unreadable push range -> nothing pending, never a guess': 3, + 'the repair dispatch -> pending exactly while the version is off npm': 2, + 'a shallow clone -> refused, never a graft-boundary answer': 1, + 'npm view -> present / absent / unknown': 5, + 'a waiting prompt -> cancelled only on the push lane, only when its version is on npm': 9, + 'an event with no release predicate -> refused': 1, +}); +const SELF_TEST_BATTERY_FLOOR = 11; + +function selfTest() { + let failed = 0; + let current = null; + const seen = new Map(); + const battery = (name) => { + current = name; + if (!seen.has(name)) seen.set(name, 0); + }; + const check = (ok, message) => { + seen.set(current, (seen.get(current) ?? 0) + 1); + if (ok) { + console.log(` ✓ ${message}`); + } else { + failed += 1; + console.error(` ✗ ${message}`); + } + }; + const throws = (fn, pattern) => { + try { + fn(); + return false; + } catch (err) { + return pattern.test(String(err && err.message)); + } + }; + + const dirs = []; + const fixture = () => { + const dir = mkdtempSync(join(tmpdir(), 'release-pending-publish-')); + dirs.push(dir); + const g = (...args) => + gitOk(dir, [ + '-c', 'user.name=fixture', + '-c', 'user.email=fixture@example.invalid', + '-c', 'commit.gpgsign=false', + '-c', 'core.hooksPath=/dev/null', + ...args, + ]); + g('init', '-q', '-b', 'main'); + const writeCli = (version, extra = {}) => { + mkdirSync(join(dir, 'packages', 'cli'), { recursive: true }); + writeFileSync(join(dir, CLI_MANIFEST), `${JSON.stringify({ name: CLI_PACKAGE, version, ...extra }, null, 2)}\n`); + }; + const commit = (message, file = null) => { + if (file) writeFileSync(join(dir, file), `${message}\n`); + g('add', '-A'); + g('commit', '-q', '-m', message); + return g('rev-parse', 'HEAD').trim(); + }; + return { dir, g, writeCli, commit }; + }; + const npmOf = (published) => (version) => (published.includes(version) ? 'present' : 'absent'); + + try { + // ── the 17.5.0 sequence, reduced ───────────────────────────────────── + const r = fixture(); + r.writeCli('1.0.0'); + const base = r.commit('base'); + r.writeCli('1.1.0'); + const v = r.commit('chore: version packages'); + const l1 = r.commit('landing one', 'one.txt'); + const l2 = r.commit('landing two', 'two.txt'); + const off = npmOf([]); + + battery('a version commit then two landings -> ONE pending publish, pinned to the version commit'); + const pushes = [ + select({ cwd: r.dir, event: 'push', before: base, head: v, npmStateOf: off }), + select({ cwd: r.dir, event: 'push', before: v, head: l1, npmStateOf: off }), + select({ cwd: r.dir, event: 'push', before: l1, head: l2, npmStateOf: off }), + ]; + check(pushes[0].pending && pushes[0].versionCommit === v, 'the push that lands the version commit is pending, on the version commit'); + check(!pushes[1].pending && pushes[1].reason === 'version-commit-landed-earlier', 'the first later landing queues nothing'); + check(!pushes[2].pending && pushes[2].reason === 'version-commit-landed-earlier', 'the second later landing queues nothing'); + check( + pushes.filter((p) => p.pending).length === 1 && pushes.every((p) => p.versionCommit === v), + 'across the three pushes: exactly one pending publish, and every push names the same version commit', + ); + + battery('a merge-queue batch carrying the version commit -> the version commit, not the head'); + const batch = select({ cwd: r.dir, event: 'push', before: base, head: l2, npmStateOf: off }); + check(batch.pending, 'a batch whose range contains the version commit is pending'); + check(batch.versionCommit === v && batch.head === l2, 'and it publishes the version commit, not the batch head'); + + battery('a published version -> nothing pending on either release event'); + const on = npmOf(['1.1.0']); + const pubPush = select({ cwd: r.dir, event: 'push', before: base, head: v, npmStateOf: on }); + check(!pubPush.pending && pubPush.reason === 'published', 'the version push itself queues nothing once the version is on npm'); + const pubDispatch = select({ cwd: r.dir, event: 'workflow_dispatch', head: l2, npmStateOf: on }); + check(!pubDispatch.pending && pubDispatch.reason === 'published', 'the repair dispatch queues nothing either'); + + battery('the repair dispatch -> pending exactly while the version is off npm'); + const dispatch = select({ cwd: r.dir, event: 'workflow_dispatch', head: l2, npmStateOf: off }); + check(dispatch.pending && dispatch.reason === 'dispatch-unpublished', 'a dispatch on an unpublished version is pending without a push range'); + check(dispatch.versionCommit === v, 'and it too publishes the version commit, not the head it was dispatched on'); + + battery('a dependency-only edit of the CLI manifest -> still the version commit'); + r.writeCli('1.1.0', { dependencies: { 'left-pad': '1.3.0' } }); + const dep = r.commit('deps: cli gains a dependency'); + const afterDep = select({ cwd: r.dir, event: 'push', before: l2, head: dep, npmStateOf: off }); + check( + afterDep.versionCommit === v && !afterDep.pending, + 'a commit that touches packages/cli/package.json without moving its version is not the version commit', + ); + + battery('an unreadable push range -> nothing pending, never a guess'); + const zero = select({ cwd: r.dir, event: 'push', before: '0'.repeat(40), head: v, npmStateOf: off }); + check(!zero.pending && zero.reason === 'range-unreadable', 'before = the all-zero sha (branch creation) -> unreadable'); + const missing = select({ cwd: r.dir, event: 'push', before: 'f'.repeat(40), head: v, npmStateOf: off }); + check(!missing.pending && missing.reason === 'range-unreadable', 'before absent from the clone -> unreadable'); + r.g('checkout', '-q', '-b', 'rewritten', base); + const orphanTip = r.commit('the tip a force-push threw away', 'gone.txt'); + r.g('checkout', '-q', 'main'); + const forced = select({ cwd: r.dir, event: 'push', before: orphanTip, head: v, npmStateOf: off }); + check(!forced.pending && forced.reason === 'range-unreadable', 'before not an ancestor of the head (a force-push) -> unreadable'); + + // ── a non-linear landing ───────────────────────────────────────────── + battery('a non-linear landing -> the merge that brought the version onto the branch'); + const n = fixture(); + n.writeCli('2.0.0'); + const nBase = n.commit('base'); + n.g('checkout', '-q', '-b', 'side'); + n.writeCli('2.1.0'); + const sideBump = n.commit('version on a side branch'); + n.g('checkout', '-q', 'main'); + const m1 = n.commit('main moves on', 'm1.txt'); + n.g('merge', '-q', '--no-ff', '-m', 'merge side', 'side'); + const merge = n.g('rev-parse', 'HEAD').trim(); + const nonLinear = select({ cwd: n.dir, event: 'push', before: m1, head: merge, npmStateOf: off }); + check( + nonLinear.versionCommit === merge && nonLinear.versionCommit !== sideBump && nonLinear.versionCommit !== nBase, + 'the version commit is the merge on the first-parent chain, not the side-branch bump', + ); + check(nonLinear.pending, 'and a push carrying that merge is pending'); + + battery('a shallow clone -> refused, never a graft-boundary answer'); + const shallowDir = mkdtempSync(join(tmpdir(), 'release-pending-publish-shallow-')); + dirs.push(shallowDir); + gitOk(tmpdir(), ['clone', '-q', '--depth', '1', `file://${r.dir}`, shallowDir]); + check( + throws(() => findVersionCommit({ cwd: shallowDir, head: 'HEAD' }), /shallow clone/), + 'a depth-1 clone -- whose only commit has no parent -- is refused, not answered', + ); + } finally { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + } + + battery('npm view -> present / absent / unknown'); + check(classifyNpmView('1.1.0', { status: 0, stdout: '1.1.0\n', stderr: '' }) === 'present', 'exit 0 printing the version -> present'); + check(classifyNpmView('1.1.0', { status: 1, stdout: '', stderr: 'npm error code E404\n' }) === 'absent', 'E404 -> absent'); + check(classifyNpmView('1.1.0', { status: 0, stdout: '', stderr: '' }) === 'absent', 'exit 0 printing nothing (no matching version) -> absent'); + check(classifyNpmView('1.1.0', { status: 1, stdout: '', stderr: 'npm error code ETIMEDOUT\n' }) === 'unknown', 'a network error -> unknown, never absent'); + check( + npmState('1.1.0', () => ({ status: 0, stdout: '1.1.0\n', stderr: '' })) === 'present' && + throws(() => npmState('1.1.0; rm -rf /', () => ({ status: 0 })), /not a version/), + 'npmState runs the classifier, and refuses a non-version argument before spawning', + ); + + battery('a waiting prompt -> cancelled only on the push lane, only when its version is on npm'); + const prompt = (version, status = 'waiting') => ({ name: `Publish ${version} to npm (awaiting approval)`, status }); + const run = (id, over = {}) => ({ + id, + event: 'push', + status: 'waiting', + jobs: [{ name: 'Release integrity (audit + no-mint backfill)', status: 'completed' }, prompt('1.1.0')], + environments: ['release'], + ...over, + }); + const judged = judgeWaitingRuns({ + currentRunId: 900, + npmStateOf: (version) => ({ '1.1.0': 'present', '1.2.0': 'absent', '1.3.0': 'unknown' })[version], + runs: [ + run(101), + run(102, { jobs: [prompt('1.2.0')] }), + run(103, { event: 'workflow_dispatch' }), + run(900), + run(104, { status: 'in_progress', jobs: [prompt('1.1.0', 'in_progress')] }), + run(105, { jobs: [prompt('1.1.0', 'in_progress')] }), + run(106, { environments: ['staging'] }), + run(107, { jobs: [prompt('1.3.0')] }), + run(108, { jobs: [{ name: 'Publish ${{ needs.release-integrity.outputs.cli-version }} to npm (awaiting approval)', status: 'waiting' }] }), + ], + }); + const where = (id) => + judged.cancel.some((c) => c.id === String(id)) + ? 'cancel' + : judged.keep.some((c) => c.id === String(id)) + ? 'keep' + : judged.untouched.some((c) => c.id === String(id)) + ? 'untouched' + : 'nowhere'; + check(where(101) === 'cancel', 'a push-lane prompt for a version already on npm -> cancelled'); + check(where(102) === 'keep', 'a push-lane prompt for a version NOT on npm -> kept (a live release)'); + check(where(103) === 'untouched', 'a dispatch prompt, even for a published version -> untouched (a human act; D4 force waits by design)'); + check(where(900) === 'untouched', 'the calling run -> untouched'); + check(where(104) === 'untouched', 'an approved, running publish -> untouched (never cancelled mid-flight)'); + check(where(105) === 'untouched', 'a waiting run whose publish job is not the waiting one -> untouched'); + check(where(106) === 'untouched', 'a prompt on another environment -> untouched'); + check(where(107) === 'keep', 'npm unreadable -> kept, never judged stale on a guess'); + check(where(108) === 'untouched', 'an unevaluated job name is no prompt -> untouched'); + + battery('an event with no release predicate -> refused'); + check( + throws(() => decidePending({ event: 'schedule', range: { state: 'in-push' }, npm: 'absent' }), /no release predicate/), + 'schedule is the bookkeeping lane and has no publish predicate at all', + ); + + // ── the floor: every declared battery ran, at or above its pin ───────── + const declared = Object.keys(SELF_TEST_BATTERIES); + const floor = (message) => { + failed += 1; + console.error(`✗ self-test floor: ${message}`); + }; + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + floor(`SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR}.`); + } + for (const [name, count] of seen) { + if (!declared.includes(name)) floor(`battery "${name}" registered ${count} case(s) but is not declared.`); + } + for (const name of declared) { + const count = seen.get(name) ?? 0; + if (count < SELF_TEST_BATTERIES[name]) { + floor( + count === 0 + ? `battery "${name}" DID NOT RUN -- 0 cases, ${SELF_TEST_BATTERIES[name]} pinned.` + : `battery "${name}" registered ${count} case(s), below its floor of ${SELF_TEST_BATTERIES[name]}.`, + ); + } + } + + const total = [...seen.values()].reduce((a, b) => a + b, 0); + if (failed > 0) { + console.error(`\n✗ release-pending-publish self-test: ${failed} failure(s) across ${declared.length} batteries.`); + process.exit(1); + } + console.log(`\n✓ release-pending-publish self-test: ${total} cases across ${declared.length} batteries pass.`); + selfTestReachedVerdict = true; +} + +// ───────────────────────────────────────────────────────────────────────────── +// CLI +// ───────────────────────────────────────────────────────────────────────────── + +function flag(args, name) { + const i = args.indexOf(name); + if (i === -1) return undefined; + const value = args[i + 1]; + if (value === undefined || value.startsWith('--')) throw new Error(`${name} needs a value`); + return value; +} + +async function main(argv) { + if (argv.includes('--self-test')) { + selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ release-pending-publish self-test: selfTest() returned without reaching its verdict, so no ' + + 'success line was printed. Exiting 0 here would report a self-test that never finished as one that passed.', + ); + process.exit(1); + } + return; + } + const [mode, ...rest] = argv; + if (mode === 'select') { + const event = flag(rest, '--event'); + const head = flag(rest, '--head'); + const before = flag(rest, '--before') || ''; + if (!event || !head) throw new Error('select needs --event and --head'); + if (event === 'push' && before && !FULL_SHA.test(before)) throw new Error(`--before is not a full sha: ${before}`); + const result = select({ cwd: process.cwd(), event, head, before, npmStateOf: (v) => npmState(v) }); + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (mode === 'npm-state') { + if (!rest[0]) throw new Error('npm-state needs a version'); + process.stdout.write(`${npmState(rest[0])}\n`); + return; + } + if (mode === 'sweep') { + await sweep({ workflow: flag(rest, '--workflow') || 'release.yml', dryRun: rest.includes('--dry-run') }); + return; + } + throw new Error(`unknown mode ${JSON.stringify(mode)} -- expected select, npm-state, sweep or --self-test`); +} + +if (isEntrypoint(import.meta.url)) { + main(process.argv.slice(2)).catch((err) => { + console.error(`::error::release-pending-publish: ${err && err.message ? err.message : err}`); + process.exit(1); + }); +}