Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .agents/pm/history/pm-github-release-merge-wait.jsonl

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions .agents/pm/issues/pm-github-release-merge-wait.toon
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
id: pm-github-release-merge-wait
title: Derive release merge wait from CI duration and refresh a safe main advance once
description: "Daily Release PR #171 exceeded the fixed 30-minute wait while required Node 22 and 26 tests were still running. Measure recent successful main CI job durations, report the bounded wait derivation, and permit one protected release-branch update when intervening commits preserve release metadata."
type: Issue
status: in_progress
priority: 1
tags[3]: changelog-unreleased,ci,release
created_at: "2026-10-23T15:47:55.215Z"
updated_at: "2026-10-23T17:04:59.966Z"
author: codex-agent
acceptance_criteria: "Workflow consumes tested decision helpers; wait floor is 60 minutes with a cap below the job timeout; one safe refresh restarts checks; release metadata drift, conflicts and repeated main advances abort; npm and Bun release gates pass."
comments[5]{created_at,author,text}:
"2026-10-23T15:47:59.125Z",codex-agent,"Plan: use 20 successful main CI runs and the successful test (22)/(26) job p95 plus max(15 minutes, 25 percent), bounded to 60-120 minutes under an explicit 360-minute job timeout. Inspect every intervening commit for package version or release-artifact changes before one leased branch refresh; restart the wait once. Use the workflow shell and real temporary git repositories for regression and revert proof."
"2026-10-23T15:54:59.525Z",codex-agent,"Live read-only execution of the workflow measurement step returned 40 successful Node 22/26 job samples from 20 main push CI runs: p95 1859 seconds plus 900 seconds margin, yielding the 3600-second floor. Focused regressions passed 24/24 and helper coverage was 100/100/100/100. Behavioral revert proof: fixed 30-minute wait failed 2 tests; bypassed metadata guard failed 8; restored abort-on-main-advance failed 3; omitted deadline restart failed 1. All mutants were restored. Rebase preserves a linear protected PR and each updated push uses the previously prepared head as its lease."
"2026-10-23T15:56:03.826Z",codex-agent,"The linked pm test runner exposed an inherited tracker-selection environment in the new no-git fixture. The fixture now passes its own explicit tracker path to pm init, so it cannot select or mutate an enclosing tracker. The 60 release workflow tests now pass through pm test, including genuine package/lock dependency updates alongside the prepared version bump."
"2026-10-23T16:32:19.971Z",codex-agent,"The complete npm release gate passed with isolated HOME and git configuration: 1045 tests, zero failures or skips, exact 100/100/100/100 coverage over 19 authored modules, 188 documented declarations, zero source duplication, approved identities and no privacy findings, production audit, package dry run, changelog check and publish-attestation check. Strict tracked-project health also passed with isolated HOME. The required Bun release gate is running against the byte-identical source candidate in an independent validation worktree so build and coverage receipts cannot collide. Native Bun already passed all 24 new regressions."
"2026-10-23T17:04:57.174Z",codex-agent,"Both required full gates passed: npm run release:check and bun run release:check each ran 1045 tests with zero failures or skips and exact 100/100/100/100 coverage across 19 authored modules. Both also passed typecheck/build, docstrings, lint, zero duplication, privacy, production audit, package dry run, changelog and publish-attestation checks. The independently installed Bun validation worktree used byte-identical source, test and documentation candidate files; npm and Bun package dry runs produced the same package hash. Workflow-focused pm test passed 60/60 and native Bun passed the 24 new regressions. Four behavioral revert controls failed as expected. Remaining validation is the PR CI matrix; live Daily Release dispatch belongs to the orchestrator after merge. Keep this item in progress for orchestrator verification."
files[3]{path,scope,note}:
.github/workflows/release.yml,project,Measured wait and one guarded release-base refresh
scripts/release-merge-policy.ts,project,CI timing and per-commit metadata policy
test/release-merge-policy.test.ts,project,Real git fixtures and complete workflow-shell regressions
tests[3]{command,scope,timeout_seconds,provenance{author,created_at,source_kind,source_ref}}:
node --test test/release-merge-policy.test.ts test/daily-release-plan.test.ts test/release-approval.test.ts test/release-workflow.test.ts,project,180,codex-agent,"2026-10-23T15:54:24.865Z",local_mutation,fix/release-merge-wait-fits-ci
"npm run release:check",project,7200,codex-agent,"2026-10-23T16:32:21.591Z",local_mutation,fix/release-merge-wait-fits-ci
"bun run release:check",project,7200,codex-agent,"2026-10-23T16:32:23.701Z",local_mutation,fix/release-merge-wait-fits-ci
docs[1]{path,scope,note}:
README.md,project,Daily Release wait derivation and safe refresh limits
body: ""
106 changes: 90 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ jobs:
# refusal could fire. The step check stays as defence in depth.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
# At most two 120-minute waits, leaving time for preparation and verification.
timeout-minutes: 360
env:
RELEASE_TIMEZONE: Europe/Vienna
steps:
Expand Down Expand Up @@ -281,28 +283,88 @@ jobs:
git commit -m "Release ${REPO_NAME} ${RELEASE_TAG}"
fi

- name: Measure release merge wait
if: steps.decide.outputs.should_release == 'true'
id: merge_wait
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
timings="$(mktemp)"
trap 'rm -f "$timings"' EXIT
# Only successful push runs on main, never PR/fork timing samples.
if run_ids="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?branch=main&event=push&status=success&per_page=20" \
--jq '.workflow_runs[].id')"; then
for run_id in $run_ids; do
if ! gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?filter=latest&per_page=100" \
--jq '.jobs[] | {name,conclusion,started_at,completed_at}' >> "$timings"; then
echo "::warning::Could not measure CI run ${run_id}; using available samples."
fi
done
else
echo "::warning::CI history unavailable; using the conservative 60-minute floor."
fi
jq -s '.' "$timings" > "${timings}.json"
trap 'rm -f "$timings" "${timings}.json"' EXIT
node --input-type=module -e '
import { readFileSync, appendFileSync } from "node:fs";
import { deriveReleaseMergeWait } from "./scripts/release-merge-policy.ts";
const plan = deriveReleaseMergeWait(JSON.parse(readFileSync(process.argv[1], "utf8")));
appendFileSync(process.env.GITHUB_OUTPUT, "wait_seconds=" + plan.wait_seconds + "\n");
const summary = "Release merge wait: " + plan.wait_seconds / 60 + " minutes. " +
"Last 20 successful main push CI runs; successful test (22)/(26) samples: " + plan.sample_count +
"; nearest-rank p95: " + plan.p95_seconds + "s; margin: max(900s, 25% of p95) = " + plan.margin_seconds +
"s; floor: 60m; cap: 120m; job timeout: 360m. One safe refresh restarts this wait.\n";
console.log(summary);
try { appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); }
catch { console.log("::warning::Could not write merge wait summary."); }
' "${timings}.json"

- name: Merge release metadata through protected PR
if: steps.decide.outputs.should_release == 'true'
id: release_pr
shell: bash
env:
GH_TOKEN: ${{ github.token }}
MERGE_WAIT_SECONDS: ${{ steps.merge_wait.outputs.wait_seconds }}
RELEASE_BASE_SHA: ${{ steps.decide.outputs.base_sha }}
RELEASE_TAG: ${{ steps.decide.outputs.tag }}
NPM_VERSION: ${{ steps.decide.outputs.npm_version }}
RELEASE_BRANCH: ${{ steps.decide.outputs.release_branch }}
RELEASE_PR_NUMBER: ${{ steps.decide.outputs.pr_number }}
run: |
set -euo pipefail
git fetch origin main --force
current_main_sha="$(git rev-parse origin/main)"
if [[ "$current_main_sha" != "$RELEASE_BASE_SHA" ]]; then
echo "::error::main advanced from ${RELEASE_BASE_SHA} to ${current_main_sha} while preparing the release. Retry from the new main head."
release_branch="$RELEASE_BRANCH"
pr_number="$RELEASE_PR_NUMBER"
release_base_sha="$RELEASE_BASE_SHA"
refreshed=0
merge_wait_seconds="${MERGE_WAIT_SECONDS:-3600}"
if [[ ! "$merge_wait_seconds" =~ ^[0-9]+$ ]] || (( merge_wait_seconds < 3600 || merge_wait_seconds > 7200 )); then
echo "::error::Release merge wait must be between 60 and 120 minutes."
exit 1
fi
# One refresh is shared by preparation and the entire wait, never one per poll.
refresh_release_base() {
git fetch origin main --force
current_main_sha="$(git rev-parse origin/main)"
if [[ "$current_main_sha" == "$release_base_sha" ]]; then return; fi
if (( refreshed )); then
echo "::error::main advanced again after the single release refresh; retry release preparation."
exit 1
fi
node --input-type=module -e '
import { assertSafeReleaseAdvance } from "./scripts/release-merge-policy.ts";
assertSafeReleaseAdvance(process.argv[1], process.argv[2]);
' "$release_base_sha" "$current_main_sha"
# A conflict fails closed; rebase keeps the protected release PR linear.
git rebase "$current_main_sha"
release_base_sha="$current_main_sha"
refreshed=1
echo "Updated release branch from main once; required CI must pass on the new head."
}
refresh_release_base

release_branch="$RELEASE_BRANCH"
pr_number="$RELEASE_PR_NUMBER"
remote_branch_sha="$(git ls-remote --heads origin "refs/heads/${release_branch}" | cut -f1)"
if [[ -z "$pr_number" && -n "$remote_branch_sha" ]]; then
echo "::error::Refusing to overwrite a release branch without a verified workflow PR."
Expand Down Expand Up @@ -342,14 +404,14 @@ jobs:

owner="${GITHUB_REPOSITORY%/*}"
repo="${GITHUB_REPOSITORY#*/}"
deadline=$(( SECONDS + 1800 ))
deadline=$(( SECONDS + merge_wait_seconds ))
awaiting_approval=0
merge_err="$(mktemp)"
pr_view_err="$(mktemp)"
merged_sha=""
while :; do
# Read mergeStateStatus + statusCheckRollup only to detect the
# fatal DIRTY/BEHIND states and to keep the deadline message
# fatal DIRTY state and to keep the deadline message
# diagnosable. The merge decision is NOT derived from this state
# (see the step-level comment above): the merge API call is the
# authority. `set -e` is active, so an unguarded `gh pr view`
Expand Down Expand Up @@ -387,12 +449,25 @@ jobs:
fi

case "$merge_state" in
DIRTY | BEHIND)
echo "::error::Release PR #${pr_number} is ${merge_state}; it conflicts with main or its base moved. Retry the release from the new main head."
DIRTY)
echo "::error::Release PR #${pr_number} is DIRTY; it conflicts with main. Retry the release from the new main head."
exit 1
;;
esac

previous_refresh="$refreshed"
refresh_release_base
if [[ "$refreshed" != "$previous_refresh" ]]; then
updated_commit="$(git rev-parse HEAD)"
git push --force-with-lease="refs/heads/${release_branch}:${release_commit}" \
origin "HEAD:refs/heads/${release_branch}"
release_commit="$updated_commit"
deadline=$(( SECONDS + merge_wait_seconds ))
# Re-read checks on the new head; never merge from the old rollup.
continue
fi
# BEHIND can lag a fetched main ref; retry without consuming a refresh.

# required_conversation_resolution is enabled, so any unresolved
# review thread blocks the merge forever. Advisory bot reviewers
# (Sourcery, cubic, CodeRabbit) routinely open threads as a
Expand Down Expand Up @@ -480,11 +555,11 @@ jobs:

if (( SECONDS >= deadline )); then
if [[ "$awaiting_approval" == 1 ]]; then
echo "::error::Release PR #${pr_number} did not merge within 30 minutes because its CI run is awaiting workflow approval (last state: ${merge_state}). Approve the run linked above once; this repository requires approval for a contributor that has no merged PR yet. Nothing was published or tagged."
echo "::error::Release PR #${pr_number} did not merge within $(( merge_wait_seconds / 60 )) minutes because its CI run is awaiting workflow approval (last state: ${merge_state}). Approve the run linked above once; this repository requires approval for a contributor that has no merged PR yet. Nothing was published or tagged."
elif [[ "$awaiting_approval" == unknown ]]; then
echo "::error::Release PR #${pr_number} did not merge within 30 minutes; current workflow approval state is unavailable (last state: ${merge_state}; still settling: ${unsettled:-none}; failing: ${failing:-none}). Inspect workflow runs and required checks; nothing was published or tagged."
echo "::error::Release PR #${pr_number} did not merge within $(( merge_wait_seconds / 60 )) minutes; current workflow approval state is unavailable (last state: ${merge_state}; still settling: ${unsettled:-none}; failing: ${failing:-none}). Inspect workflow runs and required checks; nothing was published or tagged."
else
echo "::error::Release PR #${pr_number} did not merge within 30 minutes (last state: ${merge_state}; still settling: ${unsettled:-none}; failing: ${failing:-none}). A required check failed or never reported, or a required review is missing; not merging or publishing."
echo "::error::Release PR #${pr_number} did not merge within $(( merge_wait_seconds / 60 )) minutes (last state: ${merge_state}; still settling: ${unsettled:-none}; failing: ${failing:-none}). A required check failed or never reported, or a required review is missing; not merging or publishing."
fi
exit 1
fi
Expand Down Expand Up @@ -573,9 +648,8 @@ jobs:
echo "::warning::Could not write release summary: ${summary_error}"
fi

# The release PR can only contain the prepared release commit because the
# base SHA is checked immediately before creation. Re-check the exact
# merged main commit anyway so npm always receives byte-for-byte validated
# Preparation and the optional main refresh preserve release metadata.
# Re-check the exact merged main commit so npm always receives byte-for-byte validated
# repository state, and fail safely before publication if main moved.
- name: Verify merged release
if: steps.decide.outputs.should_release == 'true'
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

### Fixed

- Derive release merge wait from CI duration and refresh a safe main advance once ([pm-github-release-merge-wait](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-release-merge-wait.toon))
- A publish spelled through a package runner was invisible to the attestation gate, so an unattested one read as clean ([pm-github-5igz](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-5igz.toon))
- PM CLI filtered native Bun tests falsely report an empty run ([pm-github-2ao1](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-2ao1.toon))
- Track pm-changelog rendering closed historical incidents as Fixed ([pm-github-n0mm](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-n0mm.toon))
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,10 @@ Release checks require type checking, docstrings, ESLint, zero source duplicatio

`npm run changelog:full` reads the complete tracker through the SDK and feeds pm-changelog. Closed work supplies release history; the explicit `changelog-unreleased` tag includes a pending candidate without closing its PM item. Other open work stays excluded.

Daily Release measures the successful `test (22)` and `test (26)` jobs from the last 20 successful main push CI runs. Its merge wait is the nearest-rank p95 duration plus the larger of 15 minutes or 25%, bounded to 60–120 minutes. Missing history uses the 60-minute floor. The log and job summary record the samples, p95, margin and bounds; the job timeout is 360 minutes.

A main advance during preparation or waiting can update the release branch once. Every intervening commit must preserve `package.json` and lockfile release versions, `CHANGELOG.md`, `manifest.json`, and release notes, including changes later reverted. Dependency-only changes are eligible. A conflict, metadata change, non-ancestor main, or second advance aborts. A successful update uses a head-specific push lease, restarts the full measured wait, and requires protected checks on the new head. Publication still verifies the merged commit and runs the release gate again.

## Multi-agent merge safety

This repo tracks its project management in `.agents/pm/` and ships a committed `.gitattributes`
Expand Down
Loading
Loading