From 2db8b302a4548469a83a61a0b99b428aced516cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 14 Apr 2026 17:32:47 +0100 Subject: [PATCH] feat(ci): add release-candidate publish pipeline (#825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ci): add release-candidate publish pipeline Auto-publishes gitnexus@rc on every merge to main. Version scheme is canonical semver X.Y.Z-rc.N where the base is the current npm 'latest' bumped by the 'bump' input (default patch) and N auto-increments by querying existing rc versions on the registry. First rc for a new base is rc.1; the counter resets naturally when the base advances after a stable release. - Reuses ci.yml via workflow_call so tests must pass before publish - SHA-pinned actions, per-job permission scoping, provenance enabled - Guard job dedupes duplicate dispatches against HEAD via v*-rc.* tags - Docs-only pushes skipped via paths-ignore - workflow_dispatch inputs: bump (patch/minor/major), force (override guard) - Publishes under the 'rc' dist-tag so 'latest' is never moved - Tags commits as v and creates GitHub prereleases * fix(ci): address release-candidate review feedback - Sort rc tags by creatordate (handles out-of-order pushes correctly) - Fail fast on npm registry errors; only fall back to package.json on E404 - Drop unused pull-requests: write permission on the reused CI job - Add secrets: inherit so any future CI secrets are available to sub-jobs - Remove unused reltag step output * fix(ci): address Copilot review comments - Correct concurrency comment (runs serialize on same ref, not overlap) - Apply E404-only fallback to 'npm view versions' query, matching the pattern used for the 'npm view version' query - README: clarify that docs-only merges don't trigger rc publish - CONTRIBUTING: drop 'from main' claim for publish.yml; the tag-push trigger does not enforce branch reachability * fix(ci): address adversarial review — idempotency, cycle continuity, tag integrity Codex adversarial review flagged three release-safety issues in the rc pipeline. Fixes: 1. Cycle continuity (H). Non-patch rc trains no longer collapse back to patch on the next push. 'bump' input accepts a new 'auto' value (default) that infers the active rc base from the registry: if any X.Y.Z-rc.* exists with X.Y.Z > latest, continue that base; otherwise patch-bump. Explicit patch/minor/major still forces a cycle reset and now also bypasses the dedup guard so an explicit dispatch on a tagged HEAD is honored. 2. Idempotency across post-publish failures (H). The guard marker ('rc/' lightweight tag) and the release tag ('v' annotated) are now pushed atomically *before* 'npm publish'. A publish failure leaves the marker in place and the guard refuses to re-publish. Added a defensive 'npm view @ version' check before publish to catch registry-level races. Recovery path documented in CONTRIBUTING.md. 3. Tag ↔ package integrity (M). 'v' now points at a detached release commit whose tree contains the rewritten package.json, so the tag's source archive matches the npm tarball exactly. 'main' stays pristine; the release commit is reachable only via the tag. * fix(ci): surface registry errors on defensive version check; drop actions: read - npm view @ version now distinguishes E404 (safe) from network failures (abort) via the same mktemp+grep pattern used for the other two npm view calls - Dropped actions: read on the ci workflow_call — no sub-workflow uses the Actions API --- .github/workflows/release-candidate.yml | 366 ++++++++++++++++++++++++ CONTRIBUTING.md | 39 +++ gitnexus/README.md | 23 ++ 3 files changed, 428 insertions(+) create mode 100644 .github/workflows/release-candidate.yml diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 0000000000..ed9bb17809 --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,366 @@ +name: Release Candidate + +on: + # Publish a release-candidate build whenever a merge/commit lands on main. + # Docs/README-only changes are filtered out so prose updates don't + # cut a release. + push: + branches: [main] + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + 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' + +# No workflow-level permissions — scoped per job below. +permissions: {} + +concurrency: + # Serialize all runs on the same ref (push + workflow_dispatch) to prevent + # two publishes racing on the rc counter. Do not cancel an in-progress run + # when a newer one is queued — we want the earlier merge to publish first. + group: release-candidate-${{ github.ref }} + cancel-in-progress: false + +jobs: + # ── Skip when HEAD already has an rc marker (retry / duplicate dispatch) ── + # The marker is a lightweight tag `rc/` pushed *before* `npm + # publish`, so a failed publish leaves the marker in place and the guard + # refuses to re-publish. Recovery path after a partial failure: + # git push --delete origin rc/ v + # then redispatch with force=true. + guard: + name: Check if release candidate should run + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + should_run: ${{ steps.decide.outputs.should_run }} + head_sha: ${{ steps.decide.outputs.head_sha }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Decide + id: decide + shell: bash + env: + FORCE: ${{ inputs.force }} + BUMP_INPUT: ${{ inputs.bump }} + EVENT_NAME: ${{ github.event_name }} + 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 + + # An explicit cycle reset on dispatch (bump != auto) also bypasses + # the dedup guard — the maintainer is deliberately asking for a + # new rc from the same commit. + 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 + + # Dedup: is there already an rc/ 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 + + # ── Reuse the stable CI workflow ───────────────────────────────────── + ci: + needs: guard + if: needs.guard.outputs.should_run == 'true' + uses: ./.github/workflows/ci.yml + permissions: + contents: read + secrets: inherit + + # ── Publish the rc build to npm + create GitHub prerelease ─────────── + publish: + name: Publish release candidate to npm + needs: [guard, ci] + if: needs.guard.outputs.should_run == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write # push rc tag + marker + id-token: write # npm provenance + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + cache: npm + cache-dependency-path: gitnexus/package-lock.json + + - name: Build gitnexus-shared + run: npm install && npm run build + working-directory: gitnexus-shared + + - name: Install gitnexus dependencies + run: npm ci + working-directory: gitnexus + + - name: Resolve rc version + id: version + 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. + NPM_STDERR_LATEST="$(mktemp)" + if CURRENT_LATEST="$(npm view "$PKG_NAME" version 2>"$NPM_STDERR_LATEST")"; then + : + else + if grep -q 'E404' "$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 for 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 -q 'E404' "$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 ∈ {patch,minor,major} → explicit cycle + # reset from latest. + # - Everything else (push, or dispatch with bump=auto) → continue + # the highest active rc base > latest if one exists; else + # default to patch from latest. + if [ "$EVENT_NAME" = "workflow_dispatch" ] \ + && [ -n "${BUMP_INPUT:-}" ] \ + && [ "${BUMP_INPUT:-auto}" != "auto" ]; then + BASE="$(npx --yes -p semver@7 semver -i "$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="$(npx --yes -p semver@7 semver -i 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 + # (e.g., race with another run), abort before re-publishing. + # Same E404-only pattern used above — a transient network + # failure must fail loudly, not pretend the version is missing. + 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" >> "$GITHUB_OUTPUT" + echo "rc_n=$NEXT_N" >> "$GITHUB_OUTPUT" + echo "rc_version=$RC_VERSION" >> "$GITHUB_OUTPUT" + + - name: Apply rc version in-CI + shell: bash + working-directory: gitnexus + run: | + set -euo pipefail + npm version "${{ steps.version.outputs.rc_version }}" \ + --no-git-tag-version --allow-same-version + + - name: Build gitnexus + run: npm run build + working-directory: gitnexus + + - name: Dry-run publish + run: npm publish --dry-run --tag rc + working-directory: gitnexus + + # ── Acquire the "rc lock" BEFORE publishing (fixes idempotency) ───── + # We create two tags and push them atomically: + # v → annotated tag on a detached release commit + # whose tree contains the rewritten package.json + # (so the tag's source matches the npm tarball) + # rc/ → lightweight tag on HEAD; the guard's dedup key + # If this push fails, nothing is published — safe. + # If this push succeeds but npm publish fails, the marker stays on + # the remote and blocks retries until an operator manually cleans up. + - name: Create and push rc tags + id: reltag + shell: bash + working-directory: gitnexus + env: + RC_VERSION: ${{ steps.version.outputs.rc_version }} + HEAD_SHA: ${{ needs.guard.outputs.head_sha }} + run: | + set -euo pipefail + VTAG="v${RC_VERSION}" + MARKER="rc/${HEAD_SHA}" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + # Detached release commit with the version bump — keeps `main` + # pristine but gives the v-tag a tree that matches the published + # package contents exactly (fixes release-integrity gap). + git add package.json package-lock.json 2>/dev/null || git add package.json + git commit -m "release: ${VTAG}" --allow-empty + RELEASE_SHA="$(git rev-parse HEAD)" + echo "Detached release commit: $RELEASE_SHA" + + # Annotated release tag on the release commit. + git tag -a "$VTAG" "$RELEASE_SHA" -m "$VTAG" + # Lightweight marker on the user-visible HEAD for the guard. + git tag "$MARKER" "$HEAD_SHA" + + # Atomic push of both refs. If either would clobber an existing + # remote ref, the push fails and we stop before npm publish. + git push --atomic origin "refs/tags/$VTAG" "refs/tags/$MARKER" + + echo "vtag=$VTAG" >> "$GITHUB_OUTPUT" + echo "marker=$MARKER" >> "$GITHUB_OUTPUT" + echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT" + + - name: Publish to npm (rc dist-tag) + run: npm publish --provenance --access public --tag rc + working-directory: gitnexus + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub prerelease + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + with: + tag_name: ${{ steps.reltag.outputs.vtag }} + name: Release Candidate ${{ steps.reltag.outputs.vtag }} + prerelease: true + make_latest: 'false' + generate_release_notes: true + body: | + Automated release candidate build from `main`. + + **npm:** `npm install gitnexus@rc` + **Version:** `${{ steps.version.outputs.rc_version }}` + **Target base:** `${{ steps.version.outputs.base }}` (rc #${{ steps.version.outputs.rc_n }}) + **Source commit (main):** ${{ needs.guard.outputs.head_sha }} + **Release commit (versioned tree):** ${{ steps.reltag.outputs.release_sha }} + + Release candidates are pre-stable builds intended for early testing. + Stable releases remain on the `latest` dist-tag. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 868b2a5808..7247750f5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,3 +48,42 @@ Maintainers may request changes for correctness, tests, performance, or consiste ## AI-assisted contributions If you use coding agents, follow project context files (e.g. `AGENTS.md`, `CLAUDE.md`) and avoid drive-by refactors unrelated to the issue. Prefer incremental, test-backed changes. + +## Releases + +Two publish workflows ship `gitnexus` to npm: + +- **Stable** (`.github/workflows/publish.yml`) — triggered by pushing any `v*` + tag. Publishes to the `latest` dist-tag with a changelog-backed GitHub + release. Maintainers are expected to tag from `main` as a convention; the + workflow itself does not enforce branch reachability. +- **Release Candidate** (`.github/workflows/release-candidate.yml`) — runs on + every push to `main` (typically a merged PR) plus manual dispatch. Docs-only + changes are skipped via `paths-ignore`. Publishes to the `rc` dist-tag with + version `X.Y.Z-rc.N` and a GitHub prerelease, where: + - `X.Y.Z` is selected automatically. On push (and on dispatch with + `bump: auto`, the default) the workflow **continues the active rc cycle**: + if the registry already has `X.Y.Z-rc.*` versions with `X.Y.Z` > current + `latest`, it reuses the highest such base; otherwise it patch-bumps + from `latest`. Dispatching with `bump: patch|minor|major` **resets** + the cycle from `latest`. + - `N` is auto-incremented against existing `X.Y.Z-rc.*` entries on the + registry. First rc for a given base is `rc.1`. + + Idempotency: the workflow pushes an `rc/` marker tag and a + `v` release tag **atomically, before** calling `npm publish`. The guard + refuses to re-run once the marker exists, so a post-publish failure will + not mint a duplicate rc for the same commit. The `v` tag points at a + detached release commit whose `package.json` matches the npm tarball + exactly (traceable releases). Recovery after a partial failure: + + ```bash + git push --delete origin rc/ v + # then redispatch the workflow with force: true + ``` + +The rc workflow never moves `latest`. To verify after a change, inspect dist-tags: + +```bash +npm view gitnexus dist-tags +``` diff --git a/gitnexus/README.md b/gitnexus/README.md index 8c7888d661..ed27bf7286 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -234,6 +234,29 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu - Node.js >= 18 - Git repository (uses git for commit tracking) +## Release candidates + +Stable releases publish to the default `latest` dist-tag. When a pull request +with non-documentation changes merges into `main`, an automated workflow also +publishes a prerelease build under the `rc` dist-tag, so early adopters can +try in-flight fixes without waiting for the next stable cut. (Docs-only +merges are skipped.) + +```bash +# Try the latest release candidate (pre-stable — may change at any time) +npm install -g gitnexus@rc +# — or — +npx gitnexus@rc analyze +``` + +Release-candidate versions follow the standard semver prerelease format +`X.Y.Z-rc.N`, where `X.Y.Z` is the next stable target (bumped from the +current `latest` by patch by default; `minor` or `major` when kicking off a +bigger cycle) and `N` increments per published rc. Example sequence: +`1.6.2-rc.1`, `1.6.2-rc.2`, …, then once `1.6.2` ships stable, +`1.6.3-rc.1`. See the [Releases page](https://github.com/abhigyanpatwari/GitNexus/releases) +for the full list; stable `latest` is unaffected. + ## Troubleshooting ### `Cannot destructure property 'package' of 'node.target' as it is null`