Daily Release #76
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Daily Release | |
| on: | |
| schedule: | |
| - cron: "23 3 * * *" | |
| workflow_dispatch: | |
| permissions: | |
| contents: write | |
| id-token: write | |
| pull-requests: write | |
| # Lets the merge wait approve a CI run that GitHub parked on `action_required` | |
| # for the release PR. Best-effort: when the token may not approve, the run is | |
| # still reported with its URL so a maintainer can approve it once. | |
| actions: write | |
| concurrency: | |
| group: daily-release | |
| cancel-in-progress: false | |
| jobs: | |
| release: | |
| # Gated at the JOB level, not only by the `Check release ref` step below. | |
| # `npm ci` runs the checked-out package's `prepare` hook, and every step - | |
| # including that one - runs with this job's `id-token: write`. A | |
| # workflow_dispatch from a feature ref would therefore execute | |
| # repository-controlled code with release privileges before any step-level | |
| # refusal could fire. The step check stays as defence in depth. | |
| if: github.ref == 'refs/heads/main' | |
| runs-on: ubuntu-latest | |
| env: | |
| RELEASE_TIMEZONE: Europe/Vienna | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| registry-url: "https://registry.npmjs.org" | |
| # node 22 ships npm 10.x, which has no trusted-publishing support at all and | |
| # would fall back to token auth. 11.5.1 is the first npm that can exchange | |
| # the workflow's OIDC id-token for a registry credential. | |
| - name: Use an npm that supports trusted publishing | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| npm install -g npm@11.19.0 | |
| # Fail closed on the EFFECTIVE version, not on having run the install. | |
| # Installing is not the same as running: a swallowed install error, a | |
| # cached shim, or a later step selecting another npm all leave 10.x | |
| # active. npm 10 has no OIDC support and would fall back to token | |
| # auth, reproducing the exact E404 this migration exists to remove - | |
| # and it would look like trusted publishing itself had failed. | |
| active="$(npm --version)" | |
| required="11.5.1" | |
| if [ "$(printf '%s\n%s\n' "$active" "$required" | sort -V | head -n 1)" != "$required" ]; then | |
| echo "::error::npm $active cannot exchange an OIDC token; $required or newer is required." | |
| exit 1 | |
| fi | |
| echo "npm $active can exchange an OIDC token for a registry credential." | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2.2.0 | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Decide release | |
| id: decide | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| git fetch --force --tags | |
| latest_tag="$(git tag --sort=-creatordate | head -n 1 || true)" | |
| if [[ -n "$latest_tag" ]] && git diff --quiet "$latest_tag"..HEAD -- .; then | |
| echo "should_release=false" >> "$GITHUB_OUTPUT" | |
| echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT" | |
| echo "No changes since $latest_tag; skipping release." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| release_date="$(TZ="$RELEASE_TIMEZONE" date +%Y.%m.%d)" | |
| base_tag="v${release_date}" | |
| max_suffix=-1 | |
| while IFS= read -r existing_tag; do | |
| if [[ "$existing_tag" == "$base_tag" ]]; then | |
| (( max_suffix < 0 )) && max_suffix=0 | |
| continue | |
| fi | |
| if [[ "$existing_tag" == "$base_tag-"* ]]; then | |
| suffix="${existing_tag#"$base_tag-"}" | |
| if [[ "$suffix" =~ ^[0-9]+$ ]] && (( suffix > max_suffix )); then | |
| max_suffix="$suffix" | |
| fi | |
| fi | |
| done < <(git tag -l "${base_tag}*") | |
| if (( max_suffix >= 0 )); then | |
| tag="${base_tag}-$((max_suffix + 1))" | |
| else | |
| tag="$base_tag" | |
| fi | |
| version_core="${tag#v}" | |
| year="${version_core%%.*}" | |
| version_tail="${version_core#*.}" | |
| month="${version_tail%%.*}" | |
| day_and_suffix="${version_tail#*.}" | |
| day="${day_and_suffix%%-*}" | |
| suffix="${day_and_suffix#"$day"}" | |
| npm_version="$((10#$year)).$((10#$month)).$((10#$day))${suffix}" | |
| echo "should_release=true" >> "$GITHUB_OUTPUT" | |
| echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT" | |
| echo "tag=$tag" >> "$GITHUB_OUTPUT" | |
| echo "npm_version=$npm_version" >> "$GITHUB_OUTPUT" | |
| echo "base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | |
| # Refuse non-main release runs BEFORE publishing. `github.ref` is the | |
| # authoritative workflow trigger ref: schedule -> refs/heads/main; | |
| # workflow_dispatch from main -> refs/heads/main; workflow_dispatch from a | |
| # feature branch -> refs/heads/<feature> (refused here). Checking the | |
| # trigger ref (rather than git topology) reliably distinguishes a feature | |
| # branch from main, which commit-topology checks cannot do (a feature | |
| # branch off main is also a descendant of origin/main). | |
| # This runs FIRST, before the OIDC preflight, and that ordering is the | |
| # point: a workflow_dispatch from a feature branch would otherwise mint an | |
| # id-token and exchange it for a short-lived npm PUBLISH CREDENTIAL, and | |
| # only then be refused - requesting a credential the run is not allowed to | |
| # use. Refusing on the ref costs nothing and must come first. | |
| - name: Check release ref | |
| if: steps.decide.outputs.should_release == 'true' | |
| shell: bash | |
| env: | |
| GITHUB_REF: ${{ github.ref }} | |
| run: | | |
| set -euo pipefail | |
| if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then | |
| echo "::error::Refusing to release from non-main ref ('$GITHUB_REF'). Run the release workflow from main." | |
| exit 1 | |
| fi | |
| # Publication is the only step that can fail for a reason outside this | |
| # repository, and it runs LAST - after the version bump and the release | |
| # commit have already landed on main. That ordering is what let a dead | |
| # credential hide for ten days: every run advanced main to a new version, | |
| # published nothing, and still reported the bump as progress. Asking the | |
| # registry for a credential BEFORE anything is mutated turns that silent | |
| # drift into an immediate, actionable failure. | |
| - name: Verify npm will accept this workflow's OIDC identity | |
| if: steps.decide.outputs.should_release == 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| pkg_name="$(node -p "require('./package.json').name")" | |
| # A scoped name contains characters that are not path-safe: the URL | |
| # segment for @unbrained/pm-web is %40unbrained%2Fpm-web, and sending | |
| # the raw name instead addresses a different path entirely. Unscoped | |
| # names encode to themselves, so this is not a no-op only for scoped | |
| # packages - it is simply correct for both. | |
| # npm's escapedName contract is NOT encodeURIComponent: the registry | |
| # preserves the leading `@` and encodes only the separator, so | |
| # @unbrained/pm-web addresses @unbrained%2fpm-web. Percent-encoding the | |
| # `@` as well produces a path the registry does not recognise. | |
| pkg_path="$(PKG="${pkg_name}" node -p "process.env.PKG.replace('/', '%2f')")" | |
| # Read defensively: under `set -u` an unset ACTIONS_ID_TOKEN_* aborts the | |
| # step with a raw shell error before the diagnosis below can print, so | |
| # the operator sees "unbound variable" instead of "the job needs | |
| # id-token: write". The whole point of this step is a legible failure. | |
| request_url="${ACTIONS_ID_TOKEN_REQUEST_URL:-}" | |
| request_token="${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" | |
| if [ -z "${request_url}" ] || [ -z "${request_token}" ]; then | |
| echo "::error::GitHub exposed no OIDC token endpoint to this job. The release job needs 'id-token: write'." | |
| exit 1 | |
| fi | |
| # `set -e` would abort on a non-zero curl before anything could be | |
| # classified, so the exit status is captured instead of propagating. | |
| if ! id_token_body="$(curl -sS --max-time 30 -H "Authorization: bearer ${request_token}" \ | |
| "${request_url}&audience=npm:registry.npmjs.org")"; then | |
| echo "::error::Could not reach GitHub's OIDC token endpoint. Nothing was bumped, committed or tagged; re-run when it is reachable." | |
| exit 1 | |
| fi | |
| id_token="$(printf '%s' "${id_token_body}" | node -p "JSON.parse(require('node:fs').readFileSync(0,'utf8')).value" 2>/dev/null)" || id_token="" | |
| if [ -z "${id_token}" ] || [ "${id_token}" = "undefined" ]; then | |
| echo "::error::GitHub would not mint an OIDC id-token. The release job needs 'id-token: write'." | |
| exit 1 | |
| fi | |
| # The response body carries a short-lived PUBLISH CREDENTIAL on success. | |
| # It is never echoed, and it must not outlive this step either: every | |
| # later step in this job runs as the same runner user and could read it | |
| # off disk. mktemp keeps it out of a predictable path and the EXIT trap | |
| # removes it on success, on failure, and on early return alike. | |
| response="$(mktemp)" | |
| trap 'rm -f "${response}"' EXIT | |
| # A timeout, DNS failure or refused connection makes curl exit non-zero, | |
| # and `set -e` would abort here - before the 000 classification below | |
| # could tell a registry outage apart from an identity refusal. Capture | |
| # the status instead, and treat "curl produced nothing" as 000. | |
| if ! status="$(curl -sS --max-time 30 -o "${response}" -w '%{http_code}' \ | |
| -X POST "https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/${pkg_path}" \ | |
| -H "Content-Type: application/json" \ | |
| -H "Authorization: Bearer ${id_token}")"; then | |
| status="000" | |
| fi | |
| [ -n "${status}" ] || status="000" | |
| # npm answers 201 Created on a successful exchange and 200 in some | |
| # paths. Accepting only one of them would fail a release whose trusted | |
| # publisher IS configured - a preflight that blocks correct releases is | |
| # worse than the outage it exists to prevent, so accept any 2xx. | |
| if [ "${status}" -ge 200 ] && [ "${status}" -lt 300 ]; then | |
| echo "npm accepted this workflow's identity for ${pkg_name}." | |
| # The exchange proves the workflow is a recognised trusted publisher. | |
| # It does NOT prove the binding permits publishing: a configuration | |
| # created on or after 2026-05-20 selects allowed actions, and one | |
| # scoped to staging alone exchanges successfully and then fails at | |
| # `npm publish` - after this run has already mutated main. Say so, | |
| # so a failure there is not read as a preflight that lied. | |
| echo "Note: this verifies identity, not the allowed action. The trusted publisher must have 'npm publish' selected." | |
| exit 0 | |
| fi | |
| reason="$(RESPONSE_FILE="${response}" node -p "try{JSON.parse(require('node:fs').readFileSync(process.env.RESPONSE_FILE,'utf8')).message||''}catch(e){''}")" | |
| # 000 is curl's "no HTTP response" (timeout, DNS, connection refused) | |
| # and 5xx is the registry failing on its own account. Neither says | |
| # anything about this workflow's identity, and telling a maintainer to | |
| # configure a trusted publisher they already configured would send them | |
| # somewhere useless. Fail either way - a release must not proceed on an | |
| # unverified identity - but say which failure it was. | |
| # 429 belongs here too: the registry is rate-limiting this caller, which | |
| # says nothing about whether a trusted publisher is bound. Sending a | |
| # maintainer to reconfigure a correct publisher is the wrong answer. | |
| if [ "${status}" = "000" ] || [ "${status}" = "429" ] || [ "${status}" -ge 500 ]; then | |
| echo "::error::Could not reach npm to verify this workflow's identity for ${pkg_name} (HTTP ${status}${reason:+: ${reason}})." | |
| echo "::error::This is a registry or network failure, NOT a trusted-publisher problem. Nothing was bumped, committed or tagged; re-run when the registry is reachable." | |
| exit 1 | |
| fi | |
| echo "::error::npm refused this workflow's OIDC identity for ${pkg_name} (HTTP ${status}${reason:+: ${reason}})." | |
| echo "::error::Nothing has been bumped, committed or tagged - this run stopped before mutating anything." | |
| echo "::error::Configure a trusted publisher on npmjs.com for ${pkg_name}: Settings -> Trusted Publisher -> GitHub Actions," | |
| echo "::error::organization 'unbraind', repository '${GITHUB_REPOSITORY#*/}', workflow 'release.yml', no environment." | |
| exit 1 | |
| - name: Update release version | |
| if: steps.decide.outputs.should_release == 'true' | |
| env: | |
| NPM_VERSION: ${{ steps.decide.outputs.npm_version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| npm version "${NPM_VERSION}" --no-git-tag-version --allow-same-version | |
| node -e "const fs=require('node:fs');const version=JSON.parse(fs.readFileSync('package.json','utf8')).version;for(const file of ['manifest.json']){if(!fs.existsSync(file))continue;const json=JSON.parse(fs.readFileSync(file,'utf8'));json.version=version;fs.writeFileSync(file,JSON.stringify(json,null,2)+'\n');}if(fs.existsSync('index.ts')){const source=fs.readFileSync('index.ts','utf8');const next=source.replace(/version:\s*[\"'][^\"']+[\"']/,'version: \"'+version+'\"');if(next!==source)fs.writeFileSync('index.ts',next);}" | |
| npm run build | |
| - name: Generate changelog and release notes | |
| if: steps.decide.outputs.should_release == 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| npx pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded --github-step-summary | |
| npx pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded --check | |
| npx pm-changelog --pm-root .agents/pm --stdout --since-previous-tag --until-release-tag --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded > RELEASE_NOTES.md | |
| - name: Run release checks | |
| if: steps.decide.outputs.should_release == 'true' | |
| run: npm run release:check | |
| - name: Commit release files | |
| if: steps.decide.outputs.should_release == 'true' | |
| env: | |
| REPO_NAME: ${{ github.event.repository.name }} | |
| RELEASE_TAG: ${{ steps.decide.outputs.tag }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| paths=(package.json package-lock.json manifest.json CHANGELOG.md) | |
| [[ -f index.ts ]] && paths+=(index.ts) | |
| [[ -d src ]] && paths+=(src) | |
| git add "${paths[@]}" | |
| if git diff --cached --quiet; then | |
| echo "Release files are already current; tagging existing commit." | |
| else | |
| git commit -m "Release ${REPO_NAME} ${RELEASE_TAG}" | |
| fi | |
| - name: Merge release metadata through protected PR | |
| if: steps.decide.outputs.should_release == 'true' | |
| id: release_pr | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_BASE_SHA: ${{ steps.decide.outputs.base_sha }} | |
| RELEASE_TAG: ${{ steps.decide.outputs.tag }} | |
| NPM_VERSION: ${{ steps.decide.outputs.npm_version }} | |
| 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." | |
| exit 1 | |
| fi | |
| release_branch="release/${RELEASE_TAG#v}" | |
| release_commit="$(git rev-parse HEAD)" | |
| if [[ "$release_commit" == "$current_main_sha" ]]; then | |
| echo "Release metadata is already present on main; resuming the publish/tag transaction." | |
| if git ls-remote --exit-code --heads origin "refs/heads/${release_branch}" > /dev/null 2>&1; then | |
| git push origin --delete "$release_branch" | |
| fi | |
| { | |
| echo "merged_sha=$current_main_sha" | |
| echo "pr_number=" | |
| } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| remote_branch_sha="$(git ls-remote --heads origin "refs/heads/${release_branch}" | cut -f1)" | |
| if [[ -n "$remote_branch_sha" ]]; then | |
| git push \ | |
| --force-with-lease="refs/heads/${release_branch}:${remote_branch_sha}" \ | |
| origin "HEAD:refs/heads/${release_branch}" | |
| else | |
| git push origin "HEAD:refs/heads/${release_branch}" | |
| fi | |
| pr_number="$(gh pr list \ | |
| --repo "$GITHUB_REPOSITORY" \ | |
| --base main \ | |
| --head "$release_branch" \ | |
| --state open \ | |
| --json number \ | |
| --jq '.[0].number // empty')" | |
| if [[ -z "$pr_number" ]]; then | |
| pr_url="$(gh pr create \ | |
| --repo "$GITHUB_REPOSITORY" \ | |
| --base main \ | |
| --head "$release_branch" \ | |
| --title "Release pm-github ${RELEASE_TAG}" \ | |
| --body "Automated daily release metadata for \`${RELEASE_TAG}\`. The release gate passed before this PR was created; npm publication and tagging remain blocked until this protected PR is merged.")" | |
| pr_number="${pr_url##*/}" | |
| fi | |
| owner="${GITHUB_REPOSITORY%/*}" | |
| repo="${GITHUB_REPOSITORY#*/}" | |
| deadline=$(( SECONDS + 1800 )) | |
| 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 | |
| # 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` | |
| # would abort the whole step on a transient 5xx/rate limit - a | |
| # failed read is not information about the PR, so it is treated as | |
| # an unrecognised state and retried. | |
| pr_state="$(gh pr view "$pr_number" \ | |
| --repo "$GITHUB_REPOSITORY" \ | |
| --json mergeStateStatus,statusCheckRollup \ | |
| --jq '{merge_state: (.mergeStateStatus // "UNKNOWN"), | |
| unsettled: [.statusCheckRollup[]? | |
| | {name: (.name // .context), | |
| state: (.conclusion // .state // .status // "PENDING")} | |
| | select((.state | IN("SUCCESS", "NEUTRAL", "SKIPPED", | |
| "FAILURE", "ERROR", "TIMED_OUT", | |
| "CANCELLED", "ACTION_REQUIRED", | |
| "STARTUP_FAILURE", "STALE")) | not) | |
| | .name], | |
| failing: [.statusCheckRollup[]? | |
| | {name: (.name // .context), | |
| state: (.conclusion // .state // .status // "PENDING")} | |
| | select(.state | IN("FAILURE", "ERROR", "TIMED_OUT", | |
| "CANCELLED", "ACTION_REQUIRED", | |
| "STARTUP_FAILURE", "STALE")) | |
| | .name]}' 2>"$pr_view_err")" || pr_state="" | |
| if [[ -z "$pr_state" ]]; then | |
| echo "Could not read PR #${pr_number} (retrying): $(tr '\n' ' ' < "$pr_view_err")" | |
| merge_state="UNKNOWN" | |
| unsettled="github api unavailable" | |
| failing="" | |
| else | |
| merge_state="$(jq -r '.merge_state' <<< "$pr_state")" | |
| unsettled="$(jq -r '.unsettled | join(", ")' <<< "$pr_state")" | |
| failing="$(jq -r '.failing | join(", ")' <<< "$pr_state")" | |
| 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." | |
| exit 1 | |
| ;; | |
| esac | |
| # 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 | |
| # confidence signal; resolve every unresolved thread on this | |
| # release PR before each attempt so a bot comment cannot dead-end | |
| # the daily release. Only BOT-authored threads on this PR are | |
| # resolved, and only when EVERY comment on the thread is bot- | |
| # authored: a human reply on a bot-opened thread must keep | |
| # blocking. Clearing a human reviewer's thread would remove the | |
| # very protection required_conversation_resolution provides. | |
| unresolved="$(gh api graphql \ | |
| -f query='query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){pullRequest(number:$n){reviewThreads(first:100){nodes{id isResolved comments(first:100){totalCount nodes{author{login __typename}}}}}}}}' \ | |
| -F o="$owner" -F r="$repo" -F n="$pr_number" \ | |
| --jq '.data.repository.pullRequest.reviewThreads.nodes[]? | |
| | select(.isResolved==false) | |
| | select(.comments.totalCount == | |
| ([.comments.nodes[]?] | length)) | |
| | select([.comments.nodes[]?.author.__typename] | |
| | length > 0 and all(. == "Bot")) | |
| | .id' 2>/dev/null || true)" | |
| if [[ -n "$unresolved" ]]; then | |
| while IFS= read -r thread_id; do | |
| [[ -z "$thread_id" ]] && continue | |
| gh api graphql \ | |
| -f query='mutation($t:ID!){resolveReviewThread(input:{threadId:$t}){thread{isResolved}}}' \ | |
| -F t="$thread_id" >/dev/null 2>&1 || true | |
| done <<< "$unresolved" | |
| fi | |
| # Attempt the merge and let GitHub be the authority. A successful | |
| # PUT is proof the branch-protection rules were satisfied; a | |
| # failure (405 = checks not ready, 409 = SHA moved) is proof they | |
| # were not. No state inference in between. | |
| merge_out="$(gh api --method PUT \ | |
| "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/merge" \ | |
| -f merge_method=rebase \ | |
| -f sha="$release_commit" \ | |
| 2>"$merge_err")" && merge_rc=0 || merge_rc=$? | |
| if (( merge_rc == 0 )); then | |
| merged_sha="$(jq -r '.sha // empty' <<< "$merge_out" 2>/dev/null || true)" | |
| if [[ -n "$merged_sha" && "$merged_sha" != "null" ]]; then | |
| echo "Merged release PR #${pr_number} via merge API (state was ${merge_state})." | |
| break | |
| fi | |
| # Merge reported success but no sha - treat as transient, retry. | |
| echo "Merge returned no sha; retrying: $(tr '\n' ' ' < "$merge_err")" | |
| merged_sha="" | |
| else | |
| echo "Merge attempt refused (state: ${merge_state}; settling: ${unsettled:-none}; failing: ${failing:-none}): $(tr '\n' ' ' < "$merge_err")" | |
| # A run parked on `action_required` is awaiting manual approval and | |
| # will never start on its own. It is invisible in statusCheckRollup, | |
| # so without this it is indistinguishable from a required check that | |
| # failed: the loop just logs "settling: none" until the deadline. | |
| # pm-changelog's release PR #133 parked exactly this way on | |
| # 2026-08-10 (attempt 1 conclusion `action_required` at 04:54:24Z, | |
| # policy `first_time_contributors`, and `github-actions[bot]` had no | |
| # merged PR in that repo yet). A human re-ran it at 05:36Z, 12 | |
| # minutes after the release had already given up. Try to approve it - | |
| # the token often may not, which is harmless - and always surface the | |
| # run so the wait is diagnosable. | |
| pending_runs="$(gh api \ | |
| "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${release_commit}&per_page=100" \ | |
| --jq '.workflow_runs[]? | select(.conclusion=="action_required" or .status=="waiting") | .id' \ | |
| 2>/dev/null || true)" | |
| if [[ -n "$pending_runs" ]]; then | |
| while IFS= read -r run_id; do | |
| [[ -z "$run_id" ]] && continue | |
| echo "::warning::CI run ${run_id} for this release PR is awaiting workflow approval: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${run_id}" | |
| gh api --method POST \ | |
| "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/approve" \ | |
| >/dev/null 2>&1 \ | |
| && echo "Approved workflow run ${run_id}; its checks can now report." \ | |
| || echo "Could not approve run ${run_id} with this token; a maintainer must approve it once." | |
| done <<< "$pending_runs" | |
| awaiting_approval=1 | |
| fi | |
| fi | |
| 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." | |
| 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." | |
| fi | |
| exit 1 | |
| fi | |
| sleep 20 | |
| done | |
| rm -f "$merge_err" "$pr_view_err" | |
| if [[ -z "$merged_sha" || "$merged_sha" == "null" ]]; then | |
| echo "::error::GitHub did not return the merged main SHA for release PR #${pr_number}." | |
| exit 1 | |
| fi | |
| if git ls-remote --exit-code --heads origin "refs/heads/${release_branch}" > /dev/null 2>&1; then | |
| git push origin --delete "$release_branch" | |
| fi | |
| { | |
| echo "merged_sha=$merged_sha" | |
| echo "pr_number=$pr_number" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "Merged release metadata PR #${pr_number} at ${merged_sha}." >> "$GITHUB_STEP_SUMMARY" | |
| # 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 | |
| # repository state, and fail safely before publication if main moved. | |
| - name: Verify merged release | |
| if: steps.decide.outputs.should_release == 'true' | |
| shell: bash | |
| env: | |
| MERGED_SHA: ${{ steps.release_pr.outputs.merged_sha }} | |
| NPM_VERSION: ${{ steps.decide.outputs.npm_version }} | |
| run: | | |
| set -euo pipefail | |
| git fetch origin main --force | |
| git checkout --detach origin/main | |
| actual_sha="$(git rev-parse HEAD)" | |
| if [[ "$actual_sha" != "$MERGED_SHA" ]]; then | |
| echo "::error::main advanced from merged release ${MERGED_SHA} to ${actual_sha} before publication. Retry to rebuild release metadata on the new head." | |
| exit 1 | |
| fi | |
| actual_version="$(npm pkg get version | tr -d '"')" | |
| if [[ "$actual_version" != "$NPM_VERSION" ]]; then | |
| echo "::error::Merged package version ${actual_version} does not match intended release ${NPM_VERSION}." | |
| exit 1 | |
| fi | |
| npm ci | |
| npm run release:check | |
| diff_paths=(package.json package-lock.json manifest.json CHANGELOG.md) | |
| [[ -f index.ts ]] && diff_paths+=(index.ts) | |
| [[ -d src ]] && diff_paths+=(src) | |
| git diff --exit-code -- "${diff_paths[@]}" | |
| # `git diff` above compares dist against itself - nothing | |
| # rebuilds before this point, so it passes unconditionally and | |
| # cannot see untracked or ignored artifacts. Rebuild from clean | |
| # so a stale committed dist cannot ship. | |
| if git ls-files --error-unmatch 'dist' > /dev/null 2>&1; then | |
| rm -rf dist | |
| npm run build | |
| dist_status="$(git status --porcelain=v1 --untracked-files=all --ignored=matching -- 'dist/')" | |
| if [[ -n "$dist_status" ]]; then | |
| echo "::error::Merged dist/ does not match a clean rebuild:" | |
| echo "$dist_status" | |
| exit 1 | |
| fi | |
| fi | |
| # Authentication is npm trusted publishing (OIDC), not a long-lived token. | |
| # The registry mints a short-lived credential from this workflow's id-token, | |
| # so no NODE_AUTH_TOKEN is set here on purpose: a stored token is the thing | |
| # that expired and silently stopped every fleet package publishing between | |
| # 2026-08-17 and 2026-08-26 while main kept bumping the version. Trusted | |
| # publishing must be configured for this package on npmjs.com against | |
| # unbraind/pm-github and this workflow filename, or publish fails closed. | |
| - name: Publish npm package | |
| id: publish | |
| if: steps.decide.outputs.should_release == 'true' | |
| shell: bash | |
| env: | |
| NPM_VERSION: ${{ steps.decide.outputs.npm_version }} | |
| run: | | |
| set -euo pipefail | |
| # actions/setup-node's registry-url writes | |
| # `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into the npm | |
| # userconfig. With no token in the environment that expands to an | |
| # EMPTY credential, and npm treats a configured-but-empty token as | |
| # legacy auth - which blocks the OIDC exchange outright and fails with | |
| # the same registry 404 this migration exists to remove. Remove any | |
| # such line before publishing so the only credential path left is OIDC. | |
| # Both files npm reads for a registry credential. `--global` and | |
| # `--location=global` write to the GLOBAL config, which the userconfig | |
| # scrub never touches, so scrubbing only one leaves the other live. | |
| globalconfig="$(npm config get globalconfig 2>/dev/null || true)" | |
| for userconfig in "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" "${globalconfig}"; do | |
| if [ -n "$userconfig" ] && [ -f "$userconfig" ]; then | |
| # Both the registry-scoped forms (//registry/:_auth=...) and the | |
| # GLOBAL forms (_auth=... at the start of a line). npm honours an | |
| # unscoped credential too, so removing only the scoped ones leaves | |
| # legacy authentication configured while every guard still passes. | |
| sed -i'' -e '/_authToken/d' \ | |
| -e '/^[[:space:]]*_auth[[:space:]]*=/d' -e '/:_auth[[:space:]]*=/d' \ | |
| -e '/^[[:space:]]*username[[:space:]]*=/d' -e '/:username[[:space:]]*=/d' \ | |
| -e '/^[[:space:]]*_password[[:space:]]*=/d' -e '/:_password[[:space:]]*=/d' \ | |
| -e '/^[[:space:]]*certfile[[:space:]]*=/d' -e '/:certfile[[:space:]]*=/d' \ | |
| -e '/^[[:space:]]*keyfile[[:space:]]*=/d' -e '/:keyfile[[:space:]]*=/d' \ | |
| -e '/always-auth/d' "$userconfig" | |
| fi | |
| done | |
| # Re-verify HERE, not only at install time. A later step can prepend a | |
| # directory to GITHUB_PATH and change which npm this step resolves, and | |
| # npm 10 cannot exchange an OIDC token - it would fall back to token | |
| # auth and reproduce the exact E404 this migration removes. | |
| active_npm="$(npm --version)" | |
| required_npm="11.5.1" | |
| if [ "$(printf '%s\n%s\n' "$active_npm" "$required_npm" | sort -V | head -n 1)" != "$required_npm" ]; then | |
| echo "::error::npm $active_npm resolved at publish time cannot exchange an OIDC token; $required_npm or newer is required." | |
| exit 1 | |
| fi | |
| pkg_name="$(node -p "require('./package.json').name")" | |
| # Idempotence guard: if the version already resolves on the registry, | |
| # treat the publish as already done and exit 0. This is what lets an | |
| # already-published release (e.g. 2026.8.10, which landed on npm while | |
| # main was still at 2026.8.7) reconcile instead of failing with a 403 | |
| # when the workflow catches up and re-runs the transaction. | |
| # Reconciliation must check the ARTIFACT, not just the version string. | |
| # `npm view <pkg>@<ver> version` proves only that something is | |
| # published under that coordinate. It cannot distinguish the attested | |
| # package this job just produced from an unattested one published | |
| # earlier, or from another commit -- so a reconcile that accepts mere | |
| # existence would tag and release the current SHA on the strength of | |
| # an artifact nobody verified. That is the very substitution this | |
| # workflow refuses to make on the publish path, so it must not make it | |
| # on the recovery path either. | |
| # | |
| # Every publish this workflow performs carries `--provenance`, so a | |
| # version of ours that landed necessarily has attestations. Their | |
| # presence is therefore the discriminator: attested means "our publish | |
| # got through", absent means "something else is sitting on this | |
| # coordinate" and is refused rather than reconciled. | |
| registry_version_is_attested() { | |
| local attestations | |
| attestations="$(npm view "${pkg_name}@${NPM_VERSION}" dist.attestations --prefer-online --json 2>/dev/null || true)" | |
| [[ -n "${attestations}" && "${attestations}" != "null" && "${attestations}" != "{}" && "${attestations}" != "[]" ]] | |
| } | |
| registry_has_version() { | |
| npm view "${pkg_name}@${NPM_VERSION}" version --prefer-online --json >/dev/null 2>&1 | |
| } | |
| # Answers one question and nothing else: is an attested copy of this | |
| # exact version visible right now? It must never terminate the step | |
| # itself -- attestation metadata can appear a moment after the version | |
| # does, and that read can fail transiently, so an `exit` in here would | |
| # turn a lag into a hard failure that skips the tag and leaves npm | |
| # ahead of Git. The retry loop decides when to stop asking; the | |
| # refusal below decides what an exhausted loop means. | |
| reconciled_attested() { | |
| registry_has_version && registry_version_is_attested | |
| } | |
| # Reached only once the loop has stopped asking. An occupied | |
| # coordinate with no attestation is the case worth naming: this | |
| # workflow only ever publishes with --provenance, so that artifact did | |
| # not come from this job, and republishing cannot repair it because npm | |
| # forbids overwriting a published version. It needs a human, and saying | |
| # so is more useful than a green run over an artifact the release notes | |
| # will misdescribe. | |
| # Declared before any function that expands it: bindings are established | |
| # before use so visibility never depends on call-time reasoning. | |
| max_attempts=3 | |
| refuse_unattested_or_fail() { | |
| if registry_has_version; then | |
| echo "::error::${pkg_name}@${NPM_VERSION} exists on the registry WITHOUT a visible provenance attestation. This workflow only ever publishes with --provenance, so either that artifact did not come from this job, or its attestation never became visible. Refusing to tag and release around it; investigate before re-running." | |
| else | |
| # Never claim the publish failed when only visibility was not | |
| # confirmed: say exactly what this run knows (pm-cli-website-3y5d). | |
| echo "::error::npm did not confirm ${pkg_name}@${NPM_VERSION} is published after ${max_attempts} publish attempts and a 10-minute visibility window. The registry shows nothing at that coordinate right now: either the publish genuinely failed, or propagation outlasted the window. Refusing to downgrade supply-chain attestations; retry the release transaction." | |
| fi | |
| exit 1 | |
| } | |
| # Idempotence guard: if this exact version is already published AND | |
| # attested, treat the publish as done. This is what lets an | |
| # already-published release (e.g. 2026.8.10, which landed on npm while | |
| # main was still at 2026.8.7) reconcile instead of failing with a 403 | |
| # when the workflow catches up and re-runs the transaction. | |
| if reconciled_attested; then | |
| echo "::notice::${pkg_name}@${NPM_VERSION} already published and attested; skipping publish step." | |
| exit 0 | |
| fi | |
| publish_with_provenance() { | |
| npm publish --access public --provenance --ignore-scripts | |
| } | |
| attempt=0 | |
| while (( attempt < max_attempts )); do | |
| attempt=$(( attempt + 1 )) | |
| if publish_with_provenance; then | |
| echo "Published with provenance on attempt ${attempt}." | |
| exit 0 | |
| fi | |
| if reconciled_attested; then | |
| echo "::notice::Version landed attested despite the reported error; treating as success." | |
| exit 0 | |
| fi | |
| if (( attempt < max_attempts )); then | |
| echo "Publish attempt ${attempt}/${max_attempts} failed; sleeping 30s before retry..." | |
| sleep 30 | |
| fi | |
| done | |
| # npm can accept a publish and still report an error, and the registry | |
| # needs a moment to propagate before `npm view` can see it. The retry | |
| # loop gave that grace incidentally, through the sleep between | |
| # attempts; the final attempt has no sleep after it, so a single | |
| # immediate read here would race propagation and fail a release npm | |
| # had already accepted -- skipping the tag and the GitHub release for a | |
| # version that is on the registry, which is the "npm ahead of git" | |
| # split the release ordering exists to prevent. Poll instead. | |
| # | |
| # 20 reads, 30 s apart: a 10-minute visibility window. The old | |
| # 5 x 30 s window closed 2.5 minutes in while npm had already | |
| # ACCEPTED the publish, printed the false "failed after 3 attempts", | |
| # and skipped the tag and the GitHub release for a version the | |
| # registry then served moments later (pm-slack/pm-web 2026-09-18: | |
| # visible 35 s after the window closed). Ten minutes absorbs the | |
| # observed propagation; --prefer-online on the registry reads keeps | |
| # a stale cache answer from faking or hiding visibility. The cost is | |
| # paid only on the path where npm has already reported an error, | |
| # never on a successful publish. | |
| reconcile_attempts=20 | |
| for reconcile_attempt in $(seq 1 "${reconcile_attempts}"); do | |
| if reconciled_attested; then | |
| echo "::notice::Version landed attested after the final reported error; treating as success." | |
| exit 0 | |
| fi | |
| echo "Not yet visible on the registry; re-reading in 30s (${reconcile_attempt}/${reconcile_attempts})..." | |
| sleep 30 | |
| done | |
| # The 20th sleep completes the 10-minute window; read once more so a | |
| # version that became visible during that final 30 s is caught too, | |
| # not failed on an arithmetic edge. | |
| if reconciled_attested; then | |
| echo "::notice::Version landed attested at the end of the 10-minute visibility window; treating as success." | |
| exit 0 | |
| fi | |
| refuse_unattested_or_fail | |
| # Tag the exact merged/verified main commit AFTER a successful publish. | |
| # main is already advanced by the protected-PR merge above, so this step | |
| # only creates and pushes the tag - it never pushes HEAD:main again (that | |
| # push was what branch protection rejected with GH006, killing the job | |
| # before the tag push in the old ordering). If publication fails, main | |
| # retains the prepared metadata and the next run resumes the same version | |
| # instead of inventing another release. | |
| - name: Push release tag | |
| id: push_tag | |
| if: steps.decide.outputs.should_release == 'true' | |
| shell: bash | |
| env: | |
| RELEASE_TAG: ${{ steps.decide.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| release_tag="$RELEASE_TAG" | |
| # Consult the remote, not just the local tag database. The last tag | |
| # fetch happened back in `Decide release`; if anything created this | |
| # tag on origin since then, the local lookup misses it, `git tag` | |
| # succeeds locally and the push below is rejected as non-fast-forward | |
| # - after a successful publish, which is the npm-ahead-of-git state | |
| # this workflow exists to prevent. | |
| git fetch origin --force --tags | |
| remote_tag_sha="$(git ls-remote --refs --tags origin "refs/tags/${release_tag}" | awk 'NR == 1 { print $1 }')" | |
| current_sha="$(git rev-parse HEAD)" | |
| if [[ -n "$remote_tag_sha" ]]; then | |
| remote_commit="$(git rev-list -n 1 "$remote_tag_sha")" | |
| if [[ "$remote_commit" != "$current_sha" ]]; then | |
| echo "::error::${release_tag} already exists on origin at ${remote_commit}, not verified main ${current_sha}." | |
| exit 1 | |
| fi | |
| echo "::notice::${release_tag} is already on origin at the verified commit; nothing to push." | |
| exit 0 | |
| fi | |
| if git rev-parse --verify --quiet "refs/tags/${release_tag}" > /dev/null; then | |
| existing_sha="$(git rev-list -n 1 "$release_tag")" | |
| current_sha="$(git rev-parse HEAD)" | |
| if [[ "$existing_sha" != "$current_sha" ]]; then | |
| echo "::error::${release_tag} already points to ${existing_sha}, not verified main ${current_sha}." | |
| exit 1 | |
| fi | |
| else | |
| git tag "$release_tag" | |
| fi | |
| git push origin "refs/tags/${release_tag}" | |
| - name: Verify bun install of published package | |
| id: verify_bun | |
| if: steps.decide.outputs.should_release == 'true' | |
| env: | |
| NPM_VERSION: ${{ steps.decide.outputs.npm_version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| pkg_name="$(node -p "require('./package.json').name")" | |
| pkg_version="${NPM_VERSION}" | |
| mkdir -p /tmp/bun-verify | |
| cd /tmp/bun-verify | |
| rm -rf node_modules bun.lockb package.json | |
| bun init -y > /dev/null | |
| # Smoke-test that the just-published version installs via bun. | |
| # Retry to absorb npm registry propagation (~60s typical). | |
| # 21 attempts with a 30 s pause BETWEEN them: the same 10-minute | |
| # window the npm reconcile uses. The last attempt runs after the | |
| # final pause, so a version that becomes installable at the very end | |
| # of the window still passes instead of failing on a trailing sleep. | |
| bun_attempts=21 | |
| for attempt in $(seq 1 "${bun_attempts}"); do | |
| if bun add "${pkg_name}@${pkg_version}"; then | |
| echo "bun add succeeded on attempt $attempt" | |
| exit 0 | |
| fi | |
| if (( attempt < bun_attempts )); then | |
| echo "bun add failed on attempt $attempt, sleeping 30s..." | |
| sleep 30 | |
| fi | |
| done | |
| # The GitHub release below is now created whenever the publish and | |
| # the tag push succeeded, regardless of this step, so a bun failure | |
| # must NOT be papered over as success. The old fallback here (treat | |
| # mirror lag as a passing verification once `npm view` confirmed the | |
| # version) still let a total failure skip the Release: pm-linear run | |
| # 35323736826 (2026-09-18) failed this step, the Release was skipped, | |
| # and tag v2026.09.18 has had no Release since. npm acceptance is | |
| # already proven by the publish step above; this step verifies bun | |
| # alone, so a failure here is reported as a failure and the gate | |
| # step below fails the job visibly. | |
| echo "::error::bun could not resolve ${pkg_name}@${pkg_version} after ${bun_attempts} attempts across a 10-minute window. npm accepted the publish and the GitHub release is created regardless of this step; this failure keeps the bun mirror problem visible instead of silent." | |
| exit 1 | |
| - name: Create GitHub release | |
| # Created even when bun verification failed: this Release used to be | |
| # skipped behind that step, which is how pm-linear v2026.09.18 ended | |
| # up tagged with no Release. It depends only on the publish and the | |
| # tag push; a bun failure is surfaced by the gate step below so | |
| # nothing goes silent. !cancelled() is required: with the default | |
| # success() condition any earlier failure would skip this step. | |
| if: >- | |
| !cancelled() && | |
| steps.publish.outcome == 'success' && | |
| steps.push_tag.outcome == 'success' | |
| env: | |
| REPO_NAME: ${{ github.event.repository.name }} | |
| RELEASE_TAG: ${{ steps.decide.outputs.tag }} | |
| GH_TOKEN: ${{ github.token }} | |
| run: gh release create "${RELEASE_TAG}" --title "${REPO_NAME} ${RELEASE_TAG}" --notes-file RELEASE_NOTES.md --verify-tag | |
| # The visible half of the bun decoupling: the Release above is created | |
| # regardless of bun verification, so without this gate a bun failure | |
| # would end in a green run and mirror lag would be invisible. Only a | |
| # bun FAILURE trips it - a skipped bun step means the publish or the | |
| # tag push already failed the job on its own. | |
| - name: Fail the job on bun verification failure | |
| if: >- | |
| !cancelled() && | |
| steps.verify_bun.outcome == 'failure' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| echo "::error::bun install verification failed (see the step log above); the npm publish, the tag push and the GitHub release were not affected. Failing the job so the bun mirror problem is not silent." | |
| exit 1 |