mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
919 lines
43 KiB
YAML
919 lines
43 KiB
YAML
name: Publish
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Sole publisher for the `gitnexus` npm package, GitHub Releases, and Docker
|
|
# images. Replaces the former two-workflow design — see issue #1609 for the
|
|
# double-publish race this unification closes.
|
|
#
|
|
# Two release modes, both routed through this file:
|
|
# • Release candidate (rc) — triggered by push to `main` or workflow_dispatch.
|
|
# The RC path computes the next rc version, applies it in-CI, pushes a
|
|
# detached release commit with v<X.Y.Z>-rc.<N> + rc/<SHA> marker
|
|
# atomically, then publishes to npm with --tag rc and creates a GitHub
|
|
# prerelease. RC-only docker.yml invocation follows.
|
|
# • Stable — triggered by push of a v<X.Y.Z> tag (no -rc.*
|
|
# suffix). Verifies package.json matches the tag, publishes to npm with
|
|
# --tag latest, creates a stable GitHub Release. No docker (RC-only).
|
|
#
|
|
# ⚠️ SELF-TRIGGER INVARIANT — DO NOT WEAKEN ⚠️
|
|
# The `tags:` filter below uses a negative glob `'!v*-rc.*'` to prevent the
|
|
# workflow from re-triggering itself when the RC path pushes its own v-tag.
|
|
# Without this exclusion, every RC publish double-fires (the bug fixed by
|
|
# #1609). If a NEW prerelease channel is introduced (e.g. `-beta.N`,
|
|
# `-alpha.N`, `-next.N`), the negative-glob list MUST be extended in
|
|
# lock-step or self-trigger returns. The same invariant applies to the
|
|
# `Classify` step further below — its accepted-tag regex must align with
|
|
# the trigger filter's exclusion list.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
paths-ignore:
|
|
- '**.md'
|
|
- 'docs/**'
|
|
- 'LICENSE'
|
|
tags:
|
|
# Negative-globbed exclusion of RC tags this workflow itself produces
|
|
# (see the SELF-TRIGGER INVARIANT in the header comment).
|
|
- 'v*'
|
|
- '!v*-rc.*'
|
|
workflow_dispatch:
|
|
inputs:
|
|
bump:
|
|
description: >-
|
|
Cycle policy. 'auto' (default) continues the active rc cycle on
|
|
this branch if there is one, otherwise bumps patch from latest.
|
|
Choose 'patch' / 'minor' / 'major' to explicitly start or reset
|
|
an rc cycle.
|
|
required: false
|
|
default: 'auto'
|
|
type: choice
|
|
options:
|
|
- auto
|
|
- patch
|
|
- minor
|
|
- major
|
|
force:
|
|
description: 'Publish even when HEAD already has an rc marker'
|
|
required: false
|
|
default: 'false'
|
|
type: choice
|
|
options:
|
|
- 'false'
|
|
- 'true'
|
|
# Workflow-level deny-all; each job declares the minimum it needs.
|
|
permissions: {}
|
|
|
|
# Distinct refs (refs/heads/main, refs/tags/v*) run in parallel. The
|
|
# release-PR-skip in rc-guard is the load-bearing invariant that prevents
|
|
# an RC main-push and a stable tag-push colliding on the same release commit.
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
# ── Phase 1: classify the triggering event into a release mode ─────────────
|
|
route:
|
|
name: Classify release event
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 2
|
|
permissions:
|
|
contents: read
|
|
outputs:
|
|
mode: ${{ steps.classify.outputs.mode }}
|
|
head_sha: ${{ steps.classify.outputs.head_sha }}
|
|
bump_input: ${{ inputs.bump }}
|
|
force_input: ${{ inputs.force }}
|
|
steps:
|
|
- name: Classify
|
|
id: classify
|
|
shell: bash
|
|
env:
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
GH_REF: ${{ github.ref }}
|
|
GH_REF_NAME: ${{ github.ref_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
HEAD_SHA="${GITHUB_SHA}"
|
|
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
|
|
|
|
# Sanitize before logging (annotation-injection defense in depth).
|
|
REF_SAFE="${GH_REF//::/__}"
|
|
REF_NAME_SAFE="${GH_REF_NAME//::/__}"
|
|
echo "event=${EVENT_NAME} ref=${REF_SAFE} ref_name=${REF_NAME_SAFE}"
|
|
|
|
MODE=""
|
|
case "${EVENT_NAME}" in
|
|
workflow_dispatch)
|
|
# Manual dispatch is only valid on main — that's the only ref
|
|
# where a real publish makes sense.
|
|
if [ "${GH_REF}" = "refs/heads/main" ]; then
|
|
MODE="rc"
|
|
else
|
|
echo "::error::workflow_dispatch is only permitted on refs/heads/main (got ${REF_SAFE})."
|
|
exit 1
|
|
fi
|
|
;;
|
|
push)
|
|
case "${GH_REF}" in
|
|
refs/heads/main)
|
|
MODE="rc"
|
|
;;
|
|
refs/tags/v*)
|
|
# The trigger filter already excluded v*-rc.* tags. Anything
|
|
# reaching here is either a stable semver or a malformed v*.
|
|
TAG="${GH_REF#refs/tags/}"
|
|
if [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
MODE="stable"
|
|
else
|
|
echo "::error::malformed v* tag rejected: ${REF_NAME_SAFE}"
|
|
echo "::error::stable tags must match ^v[0-9]+\\.[0-9]+\\.[0-9]+\$"
|
|
exit 1
|
|
fi
|
|
;;
|
|
*)
|
|
echo "::error::unexpected push ref ${REF_SAFE} reached publish workflow."
|
|
exit 1
|
|
;;
|
|
esac
|
|
;;
|
|
*)
|
|
echo "::error::unsupported event ${EVENT_NAME}."
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
echo "mode=${MODE}" >> "$GITHUB_OUTPUT"
|
|
echo "Classified as mode=${MODE}"
|
|
|
|
# ── Phase 2 (RC only): dedup marker + release-PR skip ──────────────────────
|
|
rc-guard:
|
|
name: RC guard (marker + release-PR skip)
|
|
needs: route
|
|
if: needs.route.outputs.mode == 'rc'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
permissions:
|
|
contents: read
|
|
pull-requests: read
|
|
outputs:
|
|
should_run: ${{ steps.decide.outputs.should_run }}
|
|
head_sha: ${{ steps.decide.outputs.head_sha }}
|
|
steps:
|
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
fetch-depth: 0
|
|
fetch-tags: true
|
|
# rc-guard reads only — no git pushes from this job. Skip the
|
|
# default extraheader credential persistence (artipacked audit).
|
|
persist-credentials: false
|
|
|
|
- name: Decide
|
|
id: decide
|
|
shell: bash
|
|
env:
|
|
FORCE: ${{ inputs.force }}
|
|
BUMP_INPUT: ${{ inputs.bump }}
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
REPO: ${{ github.repository }}
|
|
run: |
|
|
set -euo pipefail
|
|
HEAD_SHA=$(git rev-parse HEAD)
|
|
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
|
|
|
if [ "$FORCE" = "true" ]; then
|
|
echo "Force flag set — running regardless of marker tag."
|
|
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
# Explicit cycle reset on dispatch bypasses dedup.
|
|
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
|
|
&& [ -n "${BUMP_INPUT:-}" ] \
|
|
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
|
|
echo "Explicit bump=$BUMP_INPUT — bypassing marker dedup."
|
|
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
# ── Skip when the merge commit corresponds to a release ───────────
|
|
# This skip is load-bearing: it prevents an RC build firing on the
|
|
# release-PR commit from racing the imminent stable-tag push on the
|
|
# same SHA. Two complementary checks:
|
|
# 1. HEAD subject matches `chore: release vX.Y.Z` (the canonical
|
|
# release-PR title). Anchored to require the bare title or the
|
|
# squash-merge `(#NNNN)` suffix exactly. Case-insensitive so
|
|
# `Chore: Release v1.2.3` (IDE auto-capitalization) still
|
|
# matches — prior commit-author conventions left the door open.
|
|
# 2. Squash-merged PR carries the `release` label.
|
|
# Either match suppresses the rc build — stable releases publish on
|
|
# the v-tag instead.
|
|
HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)"
|
|
# Sanitize GitHub-Actions annotation prefixes before logging — even
|
|
# though %s strips newlines, a crafted subject containing `::error::`
|
|
# could forge log annotations.
|
|
HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}"
|
|
RELEASE_SUBJECT_RE='^chore:[[:space:]]*release[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]+\(#[0-9]+\))?$'
|
|
shopt -s nocasematch
|
|
if [[ "$HEAD_SUBJECT" =~ $RELEASE_SUBJECT_RE ]]; then
|
|
shopt -u nocasematch
|
|
echo "HEAD commit subject matches a release commit — skipping rc."
|
|
echo " subject (sanitised): $HEAD_SUBJECT_SAFE"
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
shopt -u nocasematch
|
|
|
|
# Squash-merge commits include `(#NNNN)` at the end of the subject.
|
|
if [[ "$HEAD_SUBJECT" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then
|
|
PR_NUM="${BASH_REMATCH[1]}"
|
|
echo "Detected squash-merge of PR #$PR_NUM — checking labels."
|
|
if LABELS_JSON="$(gh pr view "$PR_NUM" --repo "$REPO" --json labels 2>/dev/null)"; then
|
|
if printf '%s' "$LABELS_JSON" | jq -e '.labels[] | select(.name == "release")' >/dev/null; then
|
|
echo "PR #$PR_NUM has the 'release' label — skipping rc."
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
echo "PR #$PR_NUM has no 'release' label — proceeding."
|
|
else
|
|
# Lookup failure is not fatal — fall through to dedup check.
|
|
echo "::warning::Could not read labels for PR #${PR_NUM} — falling through."
|
|
fi
|
|
fi
|
|
|
|
# Dedup: is there already an rc/<HEAD_SHA> marker pointing at HEAD?
|
|
MARKER="rc/${HEAD_SHA}"
|
|
if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then
|
|
echo "HEAD already has marker $MARKER — skipping."
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "No marker on HEAD — proceeding."
|
|
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# ── Phase 3: reusable CI gate ──────────────────────────────────────────────
|
|
# Runs for both rc (when guard says go) and stable. No `secrets:` passed —
|
|
# ci.yml and its entire reusable-workflow chain (ci-quality, ci-tests,
|
|
# ci-e2e, ci-report) reference zero `secrets.*` values;
|
|
# passing any would be unused surface. GITHUB_TOKEN is implicit.
|
|
ci:
|
|
needs: [route, rc-guard]
|
|
if: ${{ always() && (needs.route.outputs.mode == 'stable' || needs.rc-guard.outputs.should_run == 'true') }}
|
|
uses: ./.github/workflows/ci.yml
|
|
permissions:
|
|
contents: read
|
|
actions: read
|
|
|
|
# ── Phase 4: publish to npm + push refs (RC path) ──────────────────────────
|
|
# INVARIANT: `timeout-minutes` MUST stay below the App-token TTL (~60 min
|
|
# for actions/create-github-app-token installation tokens). The atomic
|
|
# tag-push step relies on the token minted at job start; if the job ever
|
|
# runs longer than the TTL, the push fails with an opaque 401. If you
|
|
# need to raise the timeout, re-mint the token immediately before the
|
|
# `Create and push rc tags` step instead.
|
|
publish:
|
|
name: Publish to npm
|
|
needs: [route, rc-guard, ci]
|
|
if: ${{ always() && needs.ci.result == 'success' && (needs.route.outputs.mode == 'stable' || needs.rc-guard.outputs.should_run == 'true') }}
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
permissions:
|
|
# contents: write — RC path needs it for `git push --atomic` (v-tag +
|
|
# marker). Stable path runs in the same job and inherits the grant; it
|
|
# never invokes `git push`, so the elevated scope is unused there.
|
|
# id-token: write — npm provenance attestation.
|
|
contents: write
|
|
id-token: write
|
|
outputs:
|
|
# Two distinct step IDs feed this output; exactly one fires per run.
|
|
vtag: ${{ steps.rc-tags.outputs.vtag || steps.stable-vtag.outputs.vtag }}
|
|
steps:
|
|
# ── Mint short-lived GitHub App token (RC only) ──────────────────────
|
|
# Industry direction (2025-2026): GitHub Apps with
|
|
# `actions/create-github-app-token` over long-lived PATs for
|
|
# workflow-touching tag pushes. Same fine-grained permission surface,
|
|
# ~1h expiry, not tied to a user seat, organizationally auditable.
|
|
# Replaces a prior fine-grained PAT.
|
|
#
|
|
# Required secrets (set in repo Settings → Secrets and variables → Actions):
|
|
# secrets.RELEASE_APP_ID — the App's numeric ID
|
|
# secrets.RELEASE_APP_PRIVATE_KEY — the App's PEM private key
|
|
# (The App ID is technically not sensitive — it's visible on the App's
|
|
# settings page — but storing it as a secret is harmless and avoids
|
|
# mixing storage classes for the same App.)
|
|
# The App must be installed on this repository with:
|
|
# - Contents: write (push the v-tag and rc marker)
|
|
# - Workflows: write (because the v-tag's tree may touch
|
|
# .github/workflows/**, which the default
|
|
# GITHUB_TOKEN cannot author)
|
|
# - Metadata: read (required for the `gh api /users/<slug>[bot]`
|
|
# bot-identity lookup in the tag-push step)
|
|
- name: Mint GitHub App token (RC)
|
|
if: needs.route.outputs.mode == 'rc'
|
|
id: app-token
|
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
with:
|
|
# `client-id` is the renamed input that supersedes the deprecated
|
|
# `app-id` in v3.x. The action accepts the App's numeric ID or
|
|
# its Client ID under this name. We pass the numeric App ID,
|
|
# which the action resolves correctly.
|
|
client-id: ${{ secrets.RELEASE_APP_ID }}
|
|
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
|
|
|
# ── Separate checkout steps per mode ─────────────────────────────────
|
|
# Conditional `token:` expressions are footguns: empty string passed to
|
|
# actions/checkout fails opaquely, and `|| github.token` silently
|
|
# degrades a missing token to GITHUB_TOKEN, masking auth failures until
|
|
# the eventual `git push`. Two distinct steps make the auth contract
|
|
# explicit and fail loudly at checkout when the App token mint failed
|
|
# on the RC path.
|
|
- name: Checkout (RC)
|
|
if: needs.route.outputs.mode == 'rc'
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
fetch-depth: 0
|
|
fetch-tags: true
|
|
# Short-lived GitHub App installation token. Required because the
|
|
# v-tag push lands at a SHA whose tree may touch
|
|
# `.github/workflows/**`, which the default GITHUB_TOKEN cannot
|
|
# author.
|
|
token: ${{ steps.app-token.outputs.token }}
|
|
# Do not persist the token in .git/config (artipacked audit). The
|
|
# RC tag push uses an inline `http.extraheader` at push time only;
|
|
# the credential never lands on disk. See the
|
|
# `Create and push rc tags` step below.
|
|
persist-credentials: false
|
|
|
|
- name: Checkout (stable)
|
|
if: needs.route.outputs.mode == 'stable'
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable
|
|
# path performs no git pushes; the default scope is sufficient.
|
|
with:
|
|
# No git pushes from the stable path either. Skip credential
|
|
# persistence (artipacked audit).
|
|
persist-credentials: false
|
|
|
|
- name: Working-tree sanity
|
|
# Defense in depth (mirrors the vtag integrity gate, but on the input side):
|
|
# if a route-mode regression skipped both checkout `if:` gates, all
|
|
# downstream steps would run on a bare runner and produce confusing
|
|
# ENOENT errors. Fail loudly and early here instead.
|
|
shell: bash
|
|
run: |
|
|
if [ ! -f gitnexus/package.json ]; then
|
|
echo "::error::no working tree at gitnexus/package.json — route classification likely failed silently."
|
|
exit 1
|
|
fi
|
|
|
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
with:
|
|
# Node 24 ships with npm >= 11.5.x, which is the minimum that
|
|
# supports npm Trusted Publishing OIDC. Node 22 ships with npm
|
|
# 10.9.x (no OIDC) and `npm install -g npm@latest` to self-upgrade
|
|
# is fragile — it can crash the in-flight reify with
|
|
# `MODULE_NOT_FOUND` on `promise-retry` etc. Bumping the Node
|
|
# version is the clean fix; the package's `engines` field is
|
|
# `>=22.0.0` so consumer-side compatibility is unaffected (this
|
|
# Node version is only used during publish, not by package users).
|
|
node-version: 24
|
|
# `registry-url:` is intentionally OMITTED. Under npm Trusted
|
|
# Publishing, OIDC only engages when no credential is configured.
|
|
# Setting `registry-url:` would make setup-node write
|
|
# `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into the
|
|
# runner's .npmrc AND export NODE_AUTH_TOKEN from its `token:`
|
|
# input (default github.token). `npm publish` would then attempt
|
|
# GITHUB_TOKEN as the npm token, get rejected with 404, and OIDC
|
|
# would never be tried. See actions/setup-node#1440 and the GitHub
|
|
# Community discussion #176761 for the upstream bug and consensus
|
|
# workaround.
|
|
#
|
|
# Hermetic install for published artifacts — opt out of the v5+
|
|
# default packageManager-based caching (clears the zizmor
|
|
# cache-poisoning audit). ~30s slower per release; runs rarely.
|
|
package-manager-cache: false
|
|
|
|
- name: Build gitnexus-shared
|
|
run: npm ci && npm run build
|
|
working-directory: gitnexus-shared
|
|
|
|
- name: Install gitnexus dependencies
|
|
run: npm ci
|
|
working-directory: gitnexus
|
|
|
|
# ── Stable-only: verify the tag and package.json agree ───────────────
|
|
- name: Verify version consistency (stable)
|
|
if: needs.route.outputs.mode == 'stable'
|
|
shell: bash
|
|
working-directory: gitnexus
|
|
run: |
|
|
set -euo pipefail
|
|
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
|
|
# Stable mode REJECTS prerelease suffixes — those are filtered at
|
|
# trigger by the negative-glob filter, but defend at the bash layer too.
|
|
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
echo "::error::Stable tag must be ^v[0-9]+.[0-9]+.[0-9]+$ — got v$TAG_VERSION"
|
|
exit 1
|
|
fi
|
|
PKG_VERSION=$(node -p "require('./package.json').version")
|
|
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
|
|
echo "::error::Tag version (v$TAG_VERSION) does not match package.json version ($PKG_VERSION)"
|
|
exit 1
|
|
fi
|
|
# Stable releases carry their version bump on main via the release
|
|
# PR, so the manifest surfaces must already be in sync — refuse to
|
|
# publish a stable whose manifests drifted (#2445).
|
|
node scripts/sync-plugin-manifests.mjs --check
|
|
echo "Version verified: $PKG_VERSION"
|
|
|
|
# ── RC-only: compute the next rc version against the live registry ──
|
|
- name: Resolve rc version (rc)
|
|
id: rc-version
|
|
if: needs.route.outputs.mode == 'rc'
|
|
shell: bash
|
|
working-directory: gitnexus
|
|
env:
|
|
BUMP_INPUT: ${{ inputs.bump }}
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
PKG_NAME: gitnexus
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# 1. Current published `latest` — the floor for any new rc base.
|
|
# Only E404 ("never published") falls back to package.json; any
|
|
# other error (network, auth, malformed response) fails fast
|
|
# (retry-loud policy: never silently substitute on transient errors).
|
|
NPM_STDERR_LATEST="$(mktemp)"
|
|
if CURRENT_LATEST="$(npm view "$PKG_NAME" version 2>"$NPM_STDERR_LATEST")"; then
|
|
:
|
|
else
|
|
if grep -qiE 'E404|not found' "$NPM_STDERR_LATEST"; then
|
|
CURRENT_LATEST="$(node -p "require('./package.json').version")"
|
|
echo "Package not on registry (E404) — seeding from package.json: $CURRENT_LATEST"
|
|
else
|
|
echo "::error::npm registry unreachable for 'view version':" >&2
|
|
cat "$NPM_STDERR_LATEST" >&2
|
|
rm -f "$NPM_STDERR_LATEST"
|
|
exit 1
|
|
fi
|
|
fi
|
|
rm -f "$NPM_STDERR_LATEST"
|
|
CURRENT_LATEST_CLEAN="${CURRENT_LATEST%%-*}"
|
|
|
|
# 2. Full version list — needed for the counter and active-cycle
|
|
# inference. Same E404-only fallback.
|
|
NPM_STDERR_VERSIONS="$(mktemp)"
|
|
if VERSIONS_JSON="$(npm view "$PKG_NAME" versions --json 2>"$NPM_STDERR_VERSIONS")"; then
|
|
:
|
|
else
|
|
if grep -qiE 'E404|not found' "$NPM_STDERR_VERSIONS"; then
|
|
VERSIONS_JSON='[]'
|
|
echo "No published versions for $PKG_NAME yet (E404)."
|
|
else
|
|
echo "::error::npm registry unreachable for 'view versions':" >&2
|
|
cat "$NPM_STDERR_VERSIONS" >&2
|
|
rm -f "$NPM_STDERR_VERSIONS"
|
|
exit 1
|
|
fi
|
|
fi
|
|
rm -f "$NPM_STDERR_VERSIONS"
|
|
|
|
# 3. Base selection.
|
|
# - workflow_dispatch + bump != auto → explicit cycle reset.
|
|
# - Otherwise (push, or dispatch with bump=auto) → continue the
|
|
# highest active rc base > latest if any; else patch from latest.
|
|
# Curated wrapper around `npx semver` — bare npx errors are noisy
|
|
# and don't distinguish registry-unreachable from invalid-bump-spec.
|
|
semver_bump() {
|
|
local kind="$1" current="$2" stderr_file out
|
|
stderr_file="$(mktemp)"
|
|
if out="$(npx --yes -p semver@7 semver -i "$kind" "$current" 2>"$stderr_file")"; then
|
|
rm -f "$stderr_file"
|
|
printf '%s' "$out"
|
|
return 0
|
|
fi
|
|
echo "::error::semver bump failed (kind=${kind}, current=${current}):" >&2
|
|
cat "$stderr_file" >&2
|
|
rm -f "$stderr_file"
|
|
return 1
|
|
}
|
|
|
|
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
|
|
&& [ -n "${BUMP_INPUT:-}" ] \
|
|
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
|
|
BASE="$(semver_bump "$BUMP_INPUT" "$CURRENT_LATEST_CLEAN")"
|
|
echo "Explicit bump=$BUMP_INPUT → BASE=$BASE"
|
|
else
|
|
cat > /tmp/active_base.mjs <<'NODESCRIPT'
|
|
const latest = process.env.LATEST;
|
|
let v;
|
|
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
|
|
if (!Array.isArray(v)) v = [v];
|
|
const parse = s => s.split(".").map(n => parseInt(n, 10));
|
|
const gt = (a, b) => {
|
|
const [A, B] = [parse(a), parse(b)];
|
|
for (let i = 0; i < 3; i++) if (A[i] !== B[i]) return A[i] > B[i];
|
|
return false;
|
|
};
|
|
const bases = new Set();
|
|
for (const s of v) {
|
|
const m = /^(\d+\.\d+\.\d+)-rc\.\d+$/.exec(s);
|
|
if (m && gt(m[1], latest)) bases.add(m[1]);
|
|
}
|
|
if (!bases.size) { process.stdout.write(""); process.exit(0); }
|
|
const sorted = [...bases].sort((a, b) => gt(a, b) ? 1 : -1);
|
|
process.stdout.write(sorted[sorted.length - 1]);
|
|
NODESCRIPT
|
|
ACTIVE_BASE="$(LATEST="$CURRENT_LATEST_CLEAN" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/active_base.mjs)"
|
|
if [ -n "$ACTIVE_BASE" ]; then
|
|
BASE="$ACTIVE_BASE"
|
|
echo "Continuing active rc cycle → BASE=$BASE"
|
|
else
|
|
BASE="$(semver_bump patch "$CURRENT_LATEST_CLEAN")"
|
|
echo "No active rc cycle → patch bump from latest → BASE=$BASE"
|
|
fi
|
|
fi
|
|
|
|
# 4. Counter: 1 + max existing N for `${BASE}-rc.*`, else 1.
|
|
cat > /tmp/next_rc.mjs <<'NODESCRIPT'
|
|
const base = process.env.BASE;
|
|
const prefix = base + "-rc.";
|
|
let v;
|
|
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
|
|
if (!Array.isArray(v)) v = [v];
|
|
const ns = v
|
|
.filter(s => typeof s === "string" && s.startsWith(prefix))
|
|
.map(s => parseInt(s.slice(prefix.length), 10))
|
|
.filter(n => Number.isInteger(n) && n >= 0);
|
|
process.stdout.write(String(ns.length ? Math.max(...ns) + 1 : 1));
|
|
NODESCRIPT
|
|
NEXT_N="$(BASE="$BASE" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/next_rc.mjs)"
|
|
RC_VERSION="${BASE}-rc.${NEXT_N}"
|
|
echo "Computed rc: $RC_VERSION"
|
|
|
|
# 5. Defensive: if the exact version already exists on the registry
|
|
# (race with another run), abort before re-publishing.
|
|
NPM_STDERR_EXISTS="$(mktemp)"
|
|
if npm view "$PKG_NAME@$RC_VERSION" version 2>"$NPM_STDERR_EXISTS" >/dev/null; then
|
|
rm -f "$NPM_STDERR_EXISTS"
|
|
echo "::error::Version $RC_VERSION already exists on npm — aborting."
|
|
exit 1
|
|
else
|
|
if grep -qiE 'E404|not found' "$NPM_STDERR_EXISTS"; then
|
|
rm -f "$NPM_STDERR_EXISTS"
|
|
# Version doesn't exist — safe to proceed.
|
|
else
|
|
echo "::error::npm registry unreachable for existence check:" >&2
|
|
cat "$NPM_STDERR_EXISTS" >&2
|
|
rm -f "$NPM_STDERR_EXISTS"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
{
|
|
echo "base=$BASE"
|
|
echo "rc_n=$NEXT_N"
|
|
echo "rc_version=$RC_VERSION"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Apply rc version in-CI
|
|
if: needs.route.outputs.mode == 'rc'
|
|
shell: bash
|
|
working-directory: gitnexus
|
|
run: |
|
|
set -euo pipefail
|
|
npm version "${{ steps.rc-version.outputs.rc_version }}" \
|
|
--no-git-tag-version --allow-same-version
|
|
|
|
# ── Verify the plugin manifest surfaces synced (#2445) ───────────────
|
|
# The npm `version` lifecycle script in gitnexus/package.json syncs all
|
|
# four manifest surfaces whenever `npm version` runs (the step above,
|
|
# and a maintainer's laptop alike). This step only verifies fail-closed
|
|
# so a future removal of that wiring cannot ship a drifted RC again.
|
|
- name: Verify plugin manifests (rc)
|
|
if: needs.route.outputs.mode == 'rc'
|
|
shell: bash
|
|
working-directory: gitnexus
|
|
run: node scripts/sync-plugin-manifests.mjs --check
|
|
|
|
- name: Build gitnexus
|
|
run: npm run build
|
|
working-directory: gitnexus
|
|
|
|
- name: Dry-run publish
|
|
# Cheap verification that the tarball assembles before the real publish.
|
|
shell: bash
|
|
working-directory: gitnexus
|
|
env:
|
|
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || 'latest' }}
|
|
run: npm publish --dry-run --tag "$NPM_TAG"
|
|
|
|
# ── Acquire the "rc lock" BEFORE publishing (idempotency anchor) ─────
|
|
# We create two refs and push atomically:
|
|
# v<RC_VERSION> → annotated tag on a detached release commit whose
|
|
# tree contains the rewritten package.json, so the
|
|
# tag's source matches the npm tarball.
|
|
# rc/<HEAD_SHA> → lightweight tag on HEAD; the guard's dedup key.
|
|
# Push fails → nothing published. Push succeeds, npm fails → marker
|
|
# blocks retries until manual cleanup (see Rollback Runbook in plan).
|
|
- name: Create and push rc tags
|
|
id: rc-tags
|
|
if: needs.route.outputs.mode == 'rc'
|
|
shell: bash
|
|
working-directory: gitnexus
|
|
env:
|
|
RC_VERSION: ${{ steps.rc-version.outputs.rc_version }}
|
|
HEAD_SHA: ${{ needs.rc-guard.outputs.head_sha }}
|
|
# Short-lived GitHub App token. Auth is supplied inline at push
|
|
# time via `http.extraheader` (per GitHub's documented
|
|
# x-access-token Basic pattern). It is NOT persisted in
|
|
# .git/config (artipacked audit) — checkout above ran with
|
|
# `persist-credentials: false`.
|
|
PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
# App's slug from create-github-app-token (e.g. `gitnexus-release-bot`).
|
|
# Used to attribute the release commit to the App identity rather
|
|
# than the generic github-actions[bot]. The bot's numeric user-id
|
|
# is resolved at runtime via the GitHub API (the action does not
|
|
# expose it directly as of v3.2.0).
|
|
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
|
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
VTAG="v${RC_VERSION}"
|
|
MARKER="rc/${HEAD_SHA}"
|
|
|
|
# Resolve the App's bot user-id and construct the noreply email
|
|
# in the GitHub-canonical `<id>+<slug>[bot]@users.noreply.github.com`
|
|
# shape. `[bot]` is part of the actual login on GitHub.
|
|
#
|
|
# The lookup is wrapped in a bounded retry because the first RC
|
|
# after App installation may hit propagation delay (404), and
|
|
# transient api.github.com 5xx during heavy org activity is a real
|
|
# failure class. Without retry, every transient blip aborts the
|
|
# entire release after CI has already succeeded.
|
|
BOT_LOGIN="${APP_SLUG}[bot]"
|
|
BOT_USER_ID=""
|
|
api_stderr="$(mktemp)"
|
|
for attempt in 1 2 3; do
|
|
if BOT_USER_ID="$(gh api "/users/${BOT_LOGIN}" --jq .id 2>"$api_stderr")" \
|
|
&& [[ "${BOT_USER_ID}" =~ ^[0-9]+$ ]]; then
|
|
break
|
|
fi
|
|
BOT_USER_ID=""
|
|
if [ "$attempt" -lt 3 ]; then
|
|
echo "::warning::bot user-id lookup attempt ${attempt} failed; retrying in $((attempt * 5))s"
|
|
sleep $((attempt * 5))
|
|
fi
|
|
done
|
|
if ! [[ "${BOT_USER_ID}" =~ ^[0-9]+$ ]]; then
|
|
echo "::error::Could not resolve bot user-id for ${BOT_LOGIN} after 3 attempts."
|
|
echo "::error::gh api stderr:"
|
|
cat "$api_stderr" >&2 || true
|
|
echo "::error::Common causes: (a) newly-installed App — user record still propagating to /users/ (wait ~5min, redispatch with force=true); (b) App lacks Metadata: read permission; (c) transient api.github.com 5xx (redispatch)."
|
|
rm -f "$api_stderr"
|
|
exit 1
|
|
fi
|
|
rm -f "$api_stderr"
|
|
git config user.name "${BOT_LOGIN}"
|
|
git config user.email "${BOT_USER_ID}+${BOT_LOGIN}@users.noreply.github.com"
|
|
|
|
# Detached release commit with the version bump — main stays
|
|
# pristine, but the v-tag's tree matches the published package
|
|
# exactly (release-integrity).
|
|
git add package.json package-lock.json 2>/dev/null || git add package.json
|
|
# The synced manifest surfaces (#2445) belong in the same detached
|
|
# release commit so the tag's tree passes its own version contract.
|
|
git add ../gitnexus-claude-plugin/.claude-plugin/plugin.json \
|
|
../.claude-plugin/marketplace.json \
|
|
../gitnexus-claude-plugin/.codex-plugin/plugin.json \
|
|
../.agents/plugins/marketplace.json
|
|
git commit -m "release: ${VTAG}" --allow-empty
|
|
RELEASE_SHA="$(git rev-parse HEAD)"
|
|
echo "Detached release commit: $RELEASE_SHA"
|
|
|
|
git tag -a "$VTAG" "$RELEASE_SHA" -m "$VTAG"
|
|
git tag "$MARKER" "$HEAD_SHA"
|
|
|
|
# Inline auth header. The base64-encoded form is masked as well
|
|
# as the raw token, because GitHub's secret-masker only masks the
|
|
# raw value — any subsequent `set -x` / GIT_TRACE line would
|
|
# otherwise expose the encoded credential.
|
|
#
|
|
# `set +x` wraps the compute+mask pair so that if an operator
|
|
# enables ACTIONS_STEP_DEBUG=true for triage (which turns on
|
|
# `set -x` globally), the assignment is NOT traced for the one
|
|
# line between compute and mask-registration. Without this wrap,
|
|
# debug mode would log `+ auth_header='Authorization: Basic <encoded>'`
|
|
# exposing a still-valid (~1h) App token.
|
|
{ set +x; } 2>/dev/null
|
|
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${PUSH_TOKEN}" | base64 -w0)"
|
|
echo "::add-mask::${auth_header}"
|
|
# Re-enable tracing only when explicitly requested via step-debug.
|
|
if [ "${ACTIONS_STEP_DEBUG:-false}" = "true" ]; then set -x; fi
|
|
|
|
# Atomic push of both refs. If either would clobber an existing
|
|
# remote ref, the push fails and we stop before npm publish.
|
|
git -c http.extraheader="${auth_header}" \
|
|
push --atomic origin "refs/tags/$VTAG" "refs/tags/$MARKER"
|
|
|
|
{
|
|
echo "vtag=$VTAG"
|
|
echo "marker=$MARKER"
|
|
echo "release_sha=$RELEASE_SHA"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Set vtag (stable)
|
|
id: stable-vtag
|
|
if: needs.route.outputs.mode == 'stable'
|
|
shell: bash
|
|
# github.ref_name flows in via env to avoid templating into the
|
|
# shell source (template-injection audit). Even though refs are
|
|
# constrained by git naming rules, the env-passthrough pattern
|
|
# makes injection structurally impossible.
|
|
env:
|
|
REF_NAME: ${{ github.ref_name }}
|
|
run: |
|
|
echo "vtag=${REF_NAME}" >> "$GITHUB_OUTPUT"
|
|
|
|
# ── vtag integrity gate ──────────────────────────────────────────────
|
|
# Fail closed before any artifact-producing step (npm publish, Release,
|
|
# Docker) runs against an empty or mode-mismatched vtag. Prevents the
|
|
# silent "Release named main" / "Docker tagged from ref fallback"
|
|
# failure modes that the previous draft was vulnerable to.
|
|
- name: vtag integrity gate
|
|
id: vtag-gate
|
|
shell: bash
|
|
env:
|
|
MODE: ${{ needs.route.outputs.mode }}
|
|
VTAG: ${{ steps.rc-tags.outputs.vtag || steps.stable-vtag.outputs.vtag }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
if [ -z "$VTAG" ]; then
|
|
echo "::error::vtag is empty — refusing to create GitHub Release or trigger Docker."
|
|
exit 1
|
|
fi
|
|
|
|
case "$MODE" in
|
|
rc)
|
|
if ! [[ "$VTAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
|
echo "::error::vtag '${VTAG}' does not match rc shape ^v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+$"
|
|
exit 1
|
|
fi
|
|
;;
|
|
stable)
|
|
if ! [[ "$VTAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
echo "::error::vtag '${VTAG}' does not match stable shape ^v[0-9]+.[0-9]+.[0-9]+$"
|
|
exit 1
|
|
fi
|
|
;;
|
|
*)
|
|
echo "::error::unknown mode '${MODE}' at vtag integrity gate."
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
echo "vtag verified: ${VTAG} (mode=${MODE})"
|
|
echo "vtag=${VTAG}" >> "$GITHUB_OUTPUT"
|
|
|
|
# npm Trusted Publishing (GA'd 2025-07-31). OIDC authentication only
|
|
# engages when no npm credential is configured anywhere — the absence
|
|
# is the signal. Two upstream behaviors had to be neutralized for
|
|
# this to work:
|
|
#
|
|
# 1. setup-node's `registry-url:` is omitted (see the setup-node
|
|
# step above). With it, setup-node writes
|
|
# `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into
|
|
# .npmrc and exports NODE_AUTH_TOKEN from `token:` (defaulting
|
|
# to github.token). npm publish then sends GITHUB_TOKEN as the
|
|
# bearer credential and the registry returns 404. OIDC is never
|
|
# tried because npm thinks it already has a credential.
|
|
# 2. The runner's bundled npm (10.9.x on Node 22) has no OIDC
|
|
# support; the upgrade step above pins it to >= 11.5.1.
|
|
#
|
|
# Provenance is auto-attached by the registry on trusted-publisher
|
|
# publishes — no --provenance flag needed.
|
|
#
|
|
# Prerequisite: register the package as a trusted publisher at
|
|
# https://www.npmjs.com/package/gitnexus/access (Publishing access →
|
|
# Trusted Publishers → GitHub Actions):
|
|
# Owner: abhigyanpatwari
|
|
# Repository: GitNexus
|
|
# Workflow: publish.yml
|
|
# Environment: (none)
|
|
- name: Publish to npm
|
|
shell: bash
|
|
working-directory: gitnexus
|
|
env:
|
|
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || 'latest' }}
|
|
run: npm publish --access public --tag "$NPM_TAG"
|
|
|
|
# ── Stable-only: pull CHANGELOG body if present ──────────────────────
|
|
- name: Extract release notes from CHANGELOG (stable)
|
|
id: changelog
|
|
if: needs.route.outputs.mode == 'stable'
|
|
shell: bash
|
|
run: |
|
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
|
NOTES=$(awk "/^## \\[$VERSION\\]/{found=1; next} /^## \\[/{if(found) exit} found" gitnexus/CHANGELOG.md)
|
|
if [ -z "$NOTES" ]; then
|
|
echo "::warning::No CHANGELOG entry found for v$VERSION, falling back to auto-generated notes"
|
|
echo "fallback=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "$NOTES" > /tmp/release-notes.md
|
|
echo "fallback=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- name: Create GitHub Release
|
|
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v2
|
|
with:
|
|
tag_name: ${{ steps.vtag-gate.outputs.vtag }}
|
|
name: >-
|
|
${{ needs.route.outputs.mode == 'rc'
|
|
&& format('Release Candidate {0}', steps.vtag-gate.outputs.vtag)
|
|
|| steps.vtag-gate.outputs.vtag }}
|
|
prerelease: ${{ needs.route.outputs.mode == 'rc' }}
|
|
make_latest: ${{ needs.route.outputs.mode == 'stable' && 'true' || 'false' }}
|
|
# Stable: prefer CHANGELOG body, fall back to auto-generated.
|
|
# RC: always auto-generated + the prerelease body block below.
|
|
body_path: >-
|
|
${{ needs.route.outputs.mode == 'stable' && steps.changelog.outputs.fallback == 'false'
|
|
&& '/tmp/release-notes.md' || '' }}
|
|
generate_release_notes: >-
|
|
${{ needs.route.outputs.mode == 'rc'
|
|
|| steps.changelog.outputs.fallback == 'true' }}
|
|
body: >-
|
|
${{ needs.route.outputs.mode == 'rc' && format(
|
|
'Automated release candidate build from `main`.{0}{0}**npm:** `npm install gitnexus@rc`{0}**Version:** `{1}`{0}**Target base:** `{2}` (rc #{3}){0}**Source commit (main):** {4}{0}**Release commit (versioned tree):** {5}{0}{0}Release candidates are pre-stable builds intended for early testing. Stable releases remain on the `latest` dist-tag.',
|
|
'\n',
|
|
steps.rc-version.outputs.rc_version,
|
|
steps.rc-version.outputs.base,
|
|
steps.rc-version.outputs.rc_n,
|
|
needs.rc-guard.outputs.head_sha,
|
|
steps.rc-tags.outputs.release_sha
|
|
) || '' }}
|
|
|
|
# ── RC partial-failure cleanup ───────────────────────────────────────
|
|
# If anything after the atomic tag-push step failed (npm publish
|
|
# blew up, GitHub Release call timed out, etc.), the v-tag and
|
|
# rc/<SHA> marker are already on origin. External consumers
|
|
# (Renovate, Dependabot, Releases RSS) can ingest a phantom tag for
|
|
# a version that was never published to npm. This step deletes them
|
|
# automatically so the operator's recovery is just "redispatch with
|
|
# force=true on the next commit", not a manual ref cleanup.
|
|
#
|
|
# Scoped strictly to RC + real (non-dry-run) + the rc-tags step
|
|
# actually produced a vtag (otherwise nothing to clean up). The
|
|
# App token is still valid (~1h TTL, job timeout 20min).
|
|
- name: Cleanup pushed tags on partial failure
|
|
if: ${{ failure() && needs.route.outputs.mode == 'rc' && steps.rc-tags.outputs.vtag != '' }}
|
|
shell: bash
|
|
working-directory: gitnexus
|
|
env:
|
|
VTAG: ${{ steps.rc-tags.outputs.vtag }}
|
|
MARKER: ${{ steps.rc-tags.outputs.marker }}
|
|
PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
run: |
|
|
set -uo pipefail
|
|
echo "::warning::Publish step failed after tag push. Cleaning up remote refs to prevent phantom-version ingestion by downstream consumers."
|
|
|
|
{ set +x; } 2>/dev/null
|
|
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${PUSH_TOKEN}" | base64 -w0)"
|
|
echo "::add-mask::${auth_header}"
|
|
if [ "${ACTIONS_STEP_DEBUG:-false}" = "true" ]; then set -x; fi
|
|
|
|
# Delete v-tag and marker. Each delete is best-effort — if one
|
|
# is already absent (atomic push partially rejected, or earlier
|
|
# cleanup ran), the other still gets attempted.
|
|
for ref in "refs/tags/${VTAG}" "refs/tags/${MARKER}"; do
|
|
if git -c http.extraheader="${auth_header}" push origin --delete "${ref}" 2>&1; then
|
|
echo "deleted origin ${ref}"
|
|
else
|
|
echo "::warning::could not delete origin ${ref} — may already be absent or protected. Manual cleanup may be required."
|
|
fi
|
|
done
|
|
|
|
echo "::notice::Cleanup complete. To retry the release, redispatch the workflow with force=true on the same SHA, or push a new commit to main."
|
|
|
|
# ── Phase 5 (RC only): Docker images ───────────────────────────────────────
|
|
# R6: Docker remains RC-only. Stable Docker builds are explicitly deferred.
|
|
# Secrets are passed explicitly (not via `secrets: inherit`) so the
|
|
# callee's secret surface is auditable from the caller's source.
|
|
docker:
|
|
name: Build & Push RC Docker images
|
|
needs: [route, publish]
|
|
if: ${{ needs.route.outputs.mode == 'rc' && needs.publish.outputs.vtag != '' }}
|
|
uses: ./.github/workflows/docker.yml
|
|
secrets:
|
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
id-token: write
|
|
attestations: write
|
|
with:
|
|
tag: ${{ needs.publish.outputs.vtag }}
|