Skip to content

Daily Release

Daily Release #76

Workflow file for this run

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