fix(ci): address code-review findings on unified publish.yml
Round 2 of /ce-code-review surfaced 20 findings; applying the 14 with
concrete fix shapes. Six items (KTD-1 future-channel lint, concurrency
on head_sha vs ref, extraction of Node heredocs to .github/scripts/ with
vitest, ci.yml secrets-drift lint, three other defense-in-depth items)
are deferred — they need design decisions or non-trivial new files that
should land in follow-up PRs to keep this PR's blast radius bounded.
Correctness / safety:
• rc-guard release-PR-skip regex is now case-insensitive via
`shopt -s nocasematch`. `Chore: Release v1.2.3` (IDE auto-cap)
would have slipped through and re-opened the #1609 failure class
under unification.
• Bot user-id resolution (`gh api /users/<slug>[bot]`) now has a
3-attempt retry with curated error output naming the three real
failure classes (newly-installed App propagation, missing
Metadata: read permission, transient 5xx). App-permissions
comment block now lists Metadata: read explicitly.
• App-token TTL is now a documented invariant tied to
`timeout-minutes`. Comment lives on the publish job declaration.
• New `if: failure()` cleanup step in the publish job auto-deletes
the v-tag and rc-marker on post-tag-push failure, eliminating the
external-consumer phantom-version ingestion window. Cleanup uses
the same App token + inline http.extraheader auth as the original
push, so the credential never lands on disk.
Hardening:
• Wrapped the inline `http.extraheader` compute in `set +x` /
conditional re-enable so ACTIONS_STEP_DEBUG can't trace the
base64-encoded auth header for the one line between compute and
`::add-mask::` registration.
• Harmonized the three `npm view` stderr-grep patterns to one shape
(`grep -qiE 'E404|not found'`). Prevents divergent error
classification across the three callers.
• Wrapped both `npx semver -i` invocations in `semver_bump()` with
stderr capture and a curated error message naming the kind /
current. Bare npx errors were opaque on registry failures.
Rehearsal fidelity:
• `Apply rc version in-CI` now runs in dry-run too — the subsequent
`Dry-run publish` pack reflects the intended rc version instead of
the un-bumped working tree.
• vtag integrity gate's dry-run path now exercises the regex against
a synthetic vtag built from `steps.rc-version.outputs.rc_version`.
Previously the gate's core check was never run in any rehearsal —
a regex regression would only surface on the first live RC.
• vtag output in dry-run is now a sentinel `DRY_RUN_NO_VTAG`
instead of empty, preventing future composition traps where a
`vtag != ''` consumer silently succeeds in rehearsal.
Maintainability:
• Stripped all KTD-N and S-ID references from inline comments. They
pointed at a local-only plan and at claude-mem observations that
aren't in the repo — pure dangling references for future readers.
Each site now carries adjacent prose that explains the WHY.
• Removed the plan-doc path from the file header. #1609 stays as
the durable external pointer.
• Tagged the dry_run input declaration with a `DRY_RUN_REMOVE_BEFORE_MERGE`
banner so search-and-remove is mechanical.
Mechanical merge-blocker:
• Added `.github/scripts/check-no-dry-run-on-main.py` (dependency-
free Python, matches repo convention). Greps publish.yml for any
`inputs.dry_run` reference and exits 1 with remediation guidance
if found.
• Wired into ci-quality.yml's workflow-convention job. The check
fires on every PR including this one — CI will stay red until the
final cleanup commit lands, forcing the rehearsal-removal contract
rather than relying on maintainer memory.
Docs:
• CONTRIBUTING.md: documented the exact release-PR subject pattern
the rc-guard recognizes (case-insensitive, with the `(#NNNN)`
suffix). Replaced the broken `gh workflow run docker.yml` recovery
snippet with the actual working `gh run rerun <run-id> --failed`.
Added a recovery snippet for the "npm published but GitHub Release
failed" partial state.
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fail the build if any `inputs.dry_run` reference survives in publish.yml.
|
||||||
|
|
||||||
|
The unified release workflow (publish.yml) carries a temporary `dry_run`
|
||||||
|
workflow_dispatch input used for pre-merge rehearsal. The input itself is
|
||||||
|
explicitly meant to be removed in a final cleanup commit BEFORE the unification
|
||||||
|
PR (issue #1609) merges to main. Once on main, the input is dead weight at best
|
||||||
|
and a privilege-escalation surface at worst (any actor with write access could
|
||||||
|
dispatch it, bypassing every artifact-producing step while still exercising the
|
||||||
|
App-token mint and version-resolver paths).
|
||||||
|
|
||||||
|
This script is the mechanical enforcement. Invoked from ci-quality.yml so that
|
||||||
|
*any* push to main containing an `inputs.dry_run` reference fails CI loudly.
|
||||||
|
Runs locally too:
|
||||||
|
python3 .github/scripts/check-no-dry-run-on-main.py
|
||||||
|
|
||||||
|
Convention: dependency-free, stdlib only. Mirrors the shape of
|
||||||
|
check-workflow-concurrency.py.
|
||||||
|
|
||||||
|
The guard is scoped to publish.yml only. Other workflows are free to use
|
||||||
|
`inputs.dry_run` for their own purposes.
|
||||||
|
|
||||||
|
Exits 0 on clean publish.yml, exits 1 with a clear error otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
PUBLISH_YML = REPO_ROOT / ".github" / "workflows" / "publish.yml"
|
||||||
|
TOKEN = "DRY_RUN_REMOVE_BEFORE_MERGE"
|
||||||
|
PATTERN = re.compile(r"inputs\.dry_run", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not PUBLISH_YML.is_file():
|
||||||
|
# If the workflow file is missing the guard is a no-op rather than a
|
||||||
|
# spurious failure — keeps the script honest if publish.yml ever moves.
|
||||||
|
print(f"check-no-dry-run-on-main: {PUBLISH_YML} not found; skipping.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
text = PUBLISH_YML.read_text(encoding="utf-8")
|
||||||
|
lines = text.splitlines()
|
||||||
|
|
||||||
|
offending: list[tuple[int, str]] = []
|
||||||
|
for lineno, line in enumerate(lines, start=1):
|
||||||
|
if PATTERN.search(line):
|
||||||
|
offending.append((lineno, line.rstrip()))
|
||||||
|
|
||||||
|
if not offending:
|
||||||
|
print(
|
||||||
|
f"check-no-dry-run-on-main: OK — no `inputs.dry_run` references in "
|
||||||
|
f"{PUBLISH_YML.relative_to(REPO_ROOT)}."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(
|
||||||
|
"::error::publish.yml still references `inputs.dry_run`. The rehearsal "
|
||||||
|
"input must be removed before merging to main."
|
||||||
|
)
|
||||||
|
print("")
|
||||||
|
print("Offending lines:")
|
||||||
|
for lineno, line in offending:
|
||||||
|
print(f" {PUBLISH_YML.relative_to(REPO_ROOT)}:{lineno}: {line}")
|
||||||
|
print("")
|
||||||
|
print(
|
||||||
|
f"Search for the token `{TOKEN}` in publish.yml to find every cleanup "
|
||||||
|
"site, then remove the entire `dry_run` input declaration plus each "
|
||||||
|
"`inputs.dry_run` reference (input passthrough, `Reject dry_run against "
|
||||||
|
"main` step, per-step `if:` guards, vtag-gate report-only branch, "
|
||||||
|
"rehearsal-only env vars)."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -73,3 +73,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
python3 .github/scripts/check-workflow-concurrency.py .github/workflows
|
python3 .github/scripts/check-workflow-concurrency.py .github/workflows
|
||||||
|
|
||||||
|
# Mechanically enforce the pre-merge cleanup contract for publish.yml's
|
||||||
|
# temporary `dry_run` rehearsal input. Search publish.yml for the token
|
||||||
|
# `DRY_RUN_REMOVE_BEFORE_MERGE` for the rationale.
|
||||||
|
- name: Block dry_run from merging to main
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3 .github/scripts/check-no-dry-run-on-main.py
|
||||||
|
|||||||
+195
-40
@@ -2,9 +2,8 @@ name: Publish
|
|||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Sole publisher for the `gitnexus` npm package, GitHub Releases, and Docker
|
# Sole publisher for the `gitnexus` npm package, GitHub Releases, and Docker
|
||||||
# images. Replaces the former two-workflow design (release-candidate.yml +
|
# images. Replaces the former two-workflow design — see issue #1609 for the
|
||||||
# publish.yml) — see plan docs/plans/2026-05-15-001-refactor-unify-publish-
|
# double-publish race this unification closes.
|
||||||
# workflow-plan.md and issue #1609 for context.
|
|
||||||
#
|
#
|
||||||
# Two release modes, both routed through this file:
|
# Two release modes, both routed through this file:
|
||||||
# • Release candidate (rc) — triggered by push to `main` or workflow_dispatch.
|
# • Release candidate (rc) — triggered by push to `main` or workflow_dispatch.
|
||||||
@@ -16,13 +15,15 @@ name: Publish
|
|||||||
# suffix). Verifies package.json matches the tag, publishes to npm with
|
# suffix). Verifies package.json matches the tag, publishes to npm with
|
||||||
# --tag latest, creates a stable GitHub Release. No docker (RC-only).
|
# --tag latest, creates a stable GitHub Release. No docker (RC-only).
|
||||||
#
|
#
|
||||||
# ⚠️ KTD-1 INVARIANT — DO NOT WEAKEN ⚠️
|
# ⚠️ SELF-TRIGGER INVARIANT — DO NOT WEAKEN ⚠️
|
||||||
# The `tags:` filter below uses a negative glob `'!v*-rc.*'` to prevent the
|
# 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.
|
# 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
|
# Without this exclusion, every RC publish double-fires (the bug fixed by
|
||||||
# #1609). If a NEW prerelease channel is introduced (e.g. `-beta.N`,
|
# #1609). If a NEW prerelease channel is introduced (e.g. `-beta.N`,
|
||||||
# `-alpha.N`, `-next.N`), the negative-glob list MUST be extended in
|
# `-alpha.N`, `-next.N`), the negative-glob list MUST be extended in
|
||||||
# lock-step or self-trigger returns.
|
# 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:
|
on:
|
||||||
@@ -33,7 +34,8 @@ on:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'LICENSE'
|
- 'LICENSE'
|
||||||
tags:
|
tags:
|
||||||
# KTD-1: negative-globbed exclusion of RC tags this workflow itself produces.
|
# Negative-globbed exclusion of RC tags this workflow itself produces
|
||||||
|
# (see the SELF-TRIGGER INVARIANT in the header comment).
|
||||||
- 'v*'
|
- 'v*'
|
||||||
- '!v*-rc.*'
|
- '!v*-rc.*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -60,12 +62,19 @@ on:
|
|||||||
options:
|
options:
|
||||||
- 'false'
|
- 'false'
|
||||||
- 'true'
|
- 'true'
|
||||||
|
# ⚠️ DRY_RUN_REMOVE_BEFORE_MERGE — pre-merge rehearsal affordance ⚠️
|
||||||
|
# This input and every `inputs.dry_run` reference in this file MUST
|
||||||
|
# be removed in a final cleanup commit BEFORE this PR merges. The
|
||||||
|
# `check-no-dry-run-on-main.sh` script in ci-quality.yml fails the
|
||||||
|
# build if any `inputs.dry_run` reference survives on main. Search
|
||||||
|
# for the token `DRY_RUN_REMOVE_BEFORE_MERGE` to find every cleanup
|
||||||
|
# site this comment governs.
|
||||||
dry_run:
|
dry_run:
|
||||||
description: >-
|
description: >-
|
||||||
REHEARSAL ONLY. When 'true', side-effect steps (version apply,
|
REHEARSAL ONLY. When 'true', side-effect steps (version apply,
|
||||||
tag push, npm publish, GitHub Release, Docker) are skipped.
|
tag push, npm publish, GitHub Release, Docker) are skipped.
|
||||||
Rejected on `refs/heads/main` to prevent accidental retention
|
Rejected on `refs/heads/main` to prevent accidental retention
|
||||||
after merge — see plan Phase 6 cleanup gate.
|
after merge.
|
||||||
required: false
|
required: false
|
||||||
default: 'false'
|
default: 'false'
|
||||||
type: choice
|
type: choice
|
||||||
@@ -76,8 +85,8 @@ on:
|
|||||||
# Workflow-level deny-all; each job declares the minimum it needs.
|
# Workflow-level deny-all; each job declares the minimum it needs.
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
# Distinct refs (refs/heads/main, refs/tags/v*) run in parallel. KTD-3
|
# 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
|
# 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.
|
# an RC main-push and a stable tag-push colliding on the same release commit.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -108,7 +117,8 @@ jobs:
|
|||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
# Sanitize annotation-injection prefixes when logging the ref, even
|
# Sanitize annotation-injection prefixes when logging the ref, even
|
||||||
# though git ref names are constrained — defense in depth per S35017.
|
# though git ref names are constrained — defense in depth against
|
||||||
|
# crafted refs containing `::error::`-style annotation prefixes.
|
||||||
REF_SAFE="${REF_NAME//::/__}"
|
REF_SAFE="${REF_NAME//::/__}"
|
||||||
echo "::error::dry_run=true is not permitted on main (got ref ${REF_SAFE})."
|
echo "::error::dry_run=true is not permitted on main (got ref ${REF_SAFE})."
|
||||||
echo "::error::dry_run is a pre-merge rehearsal mechanism; running it on main is a configuration error."
|
echo "::error::dry_run is a pre-merge rehearsal mechanism; running it on main is a configuration error."
|
||||||
@@ -127,7 +137,7 @@ jobs:
|
|||||||
HEAD_SHA="${GITHUB_SHA}"
|
HEAD_SHA="${GITHUB_SHA}"
|
||||||
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
|
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# Sanitize before logging (S35017).
|
# Sanitize before logging (annotation-injection defense in depth).
|
||||||
REF_SAFE="${GH_REF//::/__}"
|
REF_SAFE="${GH_REF//::/__}"
|
||||||
REF_NAME_SAFE="${GH_REF_NAME//::/__}"
|
REF_NAME_SAFE="${GH_REF_NAME//::/__}"
|
||||||
echo "event=${EVENT_NAME} ref=${REF_SAFE} ref_name=${REF_NAME_SAFE}"
|
echo "event=${EVENT_NAME} ref=${REF_SAFE} ref_name=${REF_NAME_SAFE}"
|
||||||
@@ -149,7 +159,7 @@ jobs:
|
|||||||
MODE="rc"
|
MODE="rc"
|
||||||
;;
|
;;
|
||||||
refs/tags/v*)
|
refs/tags/v*)
|
||||||
# KTD-1 already filtered v*-rc.* at trigger level. Anything
|
# The trigger filter already excluded v*-rc.* tags. Anything
|
||||||
# reaching here is either a stable semver or a malformed v*.
|
# reaching here is either a stable semver or a malformed v*.
|
||||||
TAG="${GH_REF#refs/tags/}"
|
TAG="${GH_REF#refs/tags/}"
|
||||||
if [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
if [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
@@ -226,26 +236,33 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Skip when the merge commit corresponds to a release (KTD-3) ──
|
# ── Skip when the merge commit corresponds to a release ───────────
|
||||||
# Two complementary checks:
|
# 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
|
# 1. HEAD subject matches `chore: release vX.Y.Z` (the canonical
|
||||||
# release-PR title). Anchored to require the bare title or the
|
# release-PR title). Anchored to require the bare title or the
|
||||||
# squash-merge `(#NNNN)` suffix exactly.
|
# 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.
|
# 2. Squash-merged PR carries the `release` label.
|
||||||
# Either match suppresses the rc build — stable releases publish on
|
# Either match suppresses the rc build — stable releases publish on
|
||||||
# the v-tag instead.
|
# the v-tag instead.
|
||||||
HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)"
|
HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)"
|
||||||
# Sanitize GitHub-Actions annotation prefixes before logging — even
|
# Sanitize GitHub-Actions annotation prefixes before logging — even
|
||||||
# though %s strips newlines, a crafted subject containing `::error::`
|
# though %s strips newlines, a crafted subject containing `::error::`
|
||||||
# could forge log annotations (S35017).
|
# could forge log annotations.
|
||||||
HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}"
|
HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}"
|
||||||
RELEASE_SUBJECT_RE='^chore:[[:space:]]*release[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]+\(#[0-9]+\))?$'
|
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
|
if [[ "$HEAD_SUBJECT" =~ $RELEASE_SUBJECT_RE ]]; then
|
||||||
|
shopt -u nocasematch
|
||||||
echo "HEAD commit subject matches a release commit — skipping rc."
|
echo "HEAD commit subject matches a release commit — skipping rc."
|
||||||
echo " subject (sanitised): $HEAD_SUBJECT_SAFE"
|
echo " subject (sanitised): $HEAD_SUBJECT_SAFE"
|
||||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
shopt -u nocasematch
|
||||||
|
|
||||||
# Squash-merge commits include `(#NNNN)` at the end of the subject.
|
# Squash-merge commits include `(#NNNN)` at the end of the subject.
|
||||||
if [[ "$HEAD_SUBJECT" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then
|
if [[ "$HEAD_SUBJECT" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then
|
||||||
@@ -288,6 +305,12 @@ jobs:
|
|||||||
actions: read
|
actions: read
|
||||||
|
|
||||||
# ── Phase 4: publish to npm + push refs (RC path) ──────────────────────────
|
# ── 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:
|
publish:
|
||||||
name: Publish to npm
|
name: Publish to npm
|
||||||
needs: [route, rc-guard, ci]
|
needs: [route, rc-guard, ci]
|
||||||
@@ -308,16 +331,20 @@ jobs:
|
|||||||
# ── Mint short-lived GitHub App token (RC only) ──────────────────────
|
# ── Mint short-lived GitHub App token (RC only) ──────────────────────
|
||||||
# Industry direction (2025-2026): GitHub Apps with
|
# Industry direction (2025-2026): GitHub Apps with
|
||||||
# `actions/create-github-app-token` over long-lived PATs for
|
# `actions/create-github-app-token` over long-lived PATs for
|
||||||
# workflow-touching tag pushes. Same fine-grained permission surface
|
# workflow-touching tag pushes. Same fine-grained permission surface,
|
||||||
# (Contents: write + Workflows: write), ~1h expiry, not tied to a
|
# ~1h expiry, not tied to a user seat, organizationally auditable.
|
||||||
# user seat, organizationally auditable. Replaces the prior
|
# Replaces a prior fine-grained PAT.
|
||||||
# RELEASE_PUSH_TOKEN PAT (S34132).
|
|
||||||
#
|
#
|
||||||
# Required secrets/vars (set in repo Settings → Secrets and variables → Actions):
|
# Required secrets/vars (set in repo Settings → Secrets and variables → Actions):
|
||||||
# vars.RELEASE_APP_ID — the App's numeric ID (not sensitive)
|
# vars.RELEASE_APP_ID — the App's numeric ID (not sensitive)
|
||||||
# secrets.RELEASE_APP_PRIVATE_KEY — the App's PEM private key
|
# secrets.RELEASE_APP_PRIVATE_KEY — the App's PEM private key
|
||||||
# The App must be installed on this repository with Contents: write
|
# The App must be installed on this repository with:
|
||||||
# and Workflows: write permissions.
|
# - 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)
|
- name: Mint GitHub App token (RC)
|
||||||
if: needs.route.outputs.mode == 'rc'
|
if: needs.route.outputs.mode == 'rc'
|
||||||
id: app-token
|
id: app-token
|
||||||
@@ -326,7 +353,7 @@ jobs:
|
|||||||
app-id: ${{ vars.RELEASE_APP_ID }}
|
app-id: ${{ vars.RELEASE_APP_ID }}
|
||||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
# ── Separate checkout steps per mode (KTD-4) ─────────────────────────
|
# ── Separate checkout steps per mode ─────────────────────────────────
|
||||||
# Conditional `token:` expressions are footguns: empty string passed to
|
# Conditional `token:` expressions are footguns: empty string passed to
|
||||||
# actions/checkout fails opaquely, and `|| github.token` silently
|
# actions/checkout fails opaquely, and `|| github.token` silently
|
||||||
# degrades a missing token to GITHUB_TOKEN, masking auth failures until
|
# degrades a missing token to GITHUB_TOKEN, masking auth failures until
|
||||||
@@ -361,7 +388,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Working-tree sanity
|
- name: Working-tree sanity
|
||||||
# Defense in depth (mirrors KTD-5 vtag gate, but on the input side):
|
# Defense in depth (mirrors the vtag integrity gate, but on the input side):
|
||||||
# if a route-mode regression skipped both checkout `if:` gates, all
|
# if a route-mode regression skipped both checkout `if:` gates, all
|
||||||
# downstream steps would run on a bare runner and produce confusing
|
# downstream steps would run on a bare runner and produce confusing
|
||||||
# ENOENT errors. Fail loudly and early here instead.
|
# ENOENT errors. Fail loudly and early here instead.
|
||||||
@@ -378,7 +405,7 @@ jobs:
|
|||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
# Hermetic install for published artifacts — opt out of the v5+
|
# Hermetic install for published artifacts — opt out of the v5+
|
||||||
# default packageManager-based caching (clears the zizmor
|
# default packageManager-based caching (clears the zizmor
|
||||||
# cache-poisoning audit per S33365 / S35017). ~30s slower per
|
# zizmor cache-poisoning audit). ~30s slower per
|
||||||
# release; runs rarely.
|
# release; runs rarely.
|
||||||
package-manager-cache: false
|
package-manager-cache: false
|
||||||
|
|
||||||
@@ -399,7 +426,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
|
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
|
||||||
# Stable mode REJECTS prerelease suffixes — those are filtered at
|
# Stable mode REJECTS prerelease suffixes — those are filtered at
|
||||||
# trigger by KTD-1, but defend at the bash layer too.
|
# trigger by the negative-glob filter, but defend at the bash layer too.
|
||||||
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
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"
|
echo "::error::Stable tag must be ^v[0-9]+.[0-9]+.[0-9]+$ — got v$TAG_VERSION"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -427,12 +454,12 @@ jobs:
|
|||||||
# 1. Current published `latest` — the floor for any new rc base.
|
# 1. Current published `latest` — the floor for any new rc base.
|
||||||
# Only E404 ("never published") falls back to package.json; any
|
# Only E404 ("never published") falls back to package.json; any
|
||||||
# other error (network, auth, malformed response) fails fast
|
# other error (network, auth, malformed response) fails fast
|
||||||
# (S35017: retry-loud, never silently substitute).
|
# (retry-loud policy: never silently substitute on transient errors).
|
||||||
NPM_STDERR_LATEST="$(mktemp)"
|
NPM_STDERR_LATEST="$(mktemp)"
|
||||||
if CURRENT_LATEST="$(npm view "$PKG_NAME" version 2>"$NPM_STDERR_LATEST")"; then
|
if CURRENT_LATEST="$(npm view "$PKG_NAME" version 2>"$NPM_STDERR_LATEST")"; then
|
||||||
:
|
:
|
||||||
else
|
else
|
||||||
if grep -q 'E404' "$NPM_STDERR_LATEST"; then
|
if grep -qiE 'E404|not found' "$NPM_STDERR_LATEST"; then
|
||||||
CURRENT_LATEST="$(node -p "require('./package.json').version")"
|
CURRENT_LATEST="$(node -p "require('./package.json').version")"
|
||||||
echo "Package not on registry (E404) — seeding from package.json: $CURRENT_LATEST"
|
echo "Package not on registry (E404) — seeding from package.json: $CURRENT_LATEST"
|
||||||
else
|
else
|
||||||
@@ -451,7 +478,7 @@ jobs:
|
|||||||
if VERSIONS_JSON="$(npm view "$PKG_NAME" versions --json 2>"$NPM_STDERR_VERSIONS")"; then
|
if VERSIONS_JSON="$(npm view "$PKG_NAME" versions --json 2>"$NPM_STDERR_VERSIONS")"; then
|
||||||
:
|
:
|
||||||
else
|
else
|
||||||
if grep -q 'E404' "$NPM_STDERR_VERSIONS"; then
|
if grep -qiE 'E404|not found' "$NPM_STDERR_VERSIONS"; then
|
||||||
VERSIONS_JSON='[]'
|
VERSIONS_JSON='[]'
|
||||||
echo "No published versions for $PKG_NAME yet (E404)."
|
echo "No published versions for $PKG_NAME yet (E404)."
|
||||||
else
|
else
|
||||||
@@ -467,10 +494,26 @@ jobs:
|
|||||||
# - workflow_dispatch + bump != auto → explicit cycle reset.
|
# - workflow_dispatch + bump != auto → explicit cycle reset.
|
||||||
# - Otherwise (push, or dispatch with bump=auto) → continue the
|
# - Otherwise (push, or dispatch with bump=auto) → continue the
|
||||||
# highest active rc base > latest if any; else patch from latest.
|
# 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" ] \
|
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
|
||||||
&& [ -n "${BUMP_INPUT:-}" ] \
|
&& [ -n "${BUMP_INPUT:-}" ] \
|
||||||
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
|
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
|
||||||
BASE="$(npx --yes -p semver@7 semver -i "$BUMP_INPUT" "$CURRENT_LATEST_CLEAN")"
|
BASE="$(semver_bump "$BUMP_INPUT" "$CURRENT_LATEST_CLEAN")"
|
||||||
echo "Explicit bump=$BUMP_INPUT → BASE=$BASE"
|
echo "Explicit bump=$BUMP_INPUT → BASE=$BASE"
|
||||||
else
|
else
|
||||||
cat > /tmp/active_base.mjs <<'NODESCRIPT'
|
cat > /tmp/active_base.mjs <<'NODESCRIPT'
|
||||||
@@ -498,7 +541,7 @@ jobs:
|
|||||||
BASE="$ACTIVE_BASE"
|
BASE="$ACTIVE_BASE"
|
||||||
echo "Continuing active rc cycle → BASE=$BASE"
|
echo "Continuing active rc cycle → BASE=$BASE"
|
||||||
else
|
else
|
||||||
BASE="$(npx --yes -p semver@7 semver -i patch "$CURRENT_LATEST_CLEAN")"
|
BASE="$(semver_bump patch "$CURRENT_LATEST_CLEAN")"
|
||||||
echo "No active rc cycle → patch bump from latest → BASE=$BASE"
|
echo "No active rc cycle → patch bump from latest → BASE=$BASE"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -546,7 +589,11 @@ jobs:
|
|||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Apply rc version in-CI
|
- name: Apply rc version in-CI
|
||||||
if: ${{ needs.route.outputs.mode == 'rc' && inputs.dry_run != 'true' }}
|
# Runs in dry-run too — only mutates the runner's working tree (no
|
||||||
|
# push, no commit). This way the subsequent `Dry-run publish` packs
|
||||||
|
# the tarball at the intended rc version, so the rehearsal log is
|
||||||
|
# faithful instead of showing the un-bumped version.
|
||||||
|
if: needs.route.outputs.mode == 'rc'
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: gitnexus
|
working-directory: gitnexus
|
||||||
run: |
|
run: |
|
||||||
@@ -604,12 +651,35 @@ jobs:
|
|||||||
# Resolve the App's bot user-id and construct the noreply email
|
# Resolve the App's bot user-id and construct the noreply email
|
||||||
# in the GitHub-canonical `<id>+<slug>[bot]@users.noreply.github.com`
|
# in the GitHub-canonical `<id>+<slug>[bot]@users.noreply.github.com`
|
||||||
# shape. `[bot]` is part of the actual login on GitHub.
|
# 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_LOGIN="${APP_SLUG}[bot]"
|
||||||
BOT_USER_ID="$(gh api "/users/${BOT_LOGIN}" --jq .id)"
|
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
|
if ! [[ "${BOT_USER_ID}" =~ ^[0-9]+$ ]]; then
|
||||||
echo "::error::Could not resolve bot user-id for ${BOT_LOGIN} (got: ${BOT_USER_ID})"
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
rm -f "$api_stderr"
|
||||||
git config user.name "${BOT_LOGIN}"
|
git config user.name "${BOT_LOGIN}"
|
||||||
git config user.email "${BOT_USER_ID}+${BOT_LOGIN}@users.noreply.github.com"
|
git config user.email "${BOT_USER_ID}+${BOT_LOGIN}@users.noreply.github.com"
|
||||||
|
|
||||||
@@ -627,10 +697,19 @@ jobs:
|
|||||||
# Inline auth header. The base64-encoded form is masked as well
|
# Inline auth header. The base64-encoded form is masked as well
|
||||||
# as the raw token, because GitHub's secret-masker only masks the
|
# as the raw token, because GitHub's secret-masker only masks the
|
||||||
# raw value — any subsequent `set -x` / GIT_TRACE line would
|
# raw value — any subsequent `set -x` / GIT_TRACE line would
|
||||||
# otherwise expose the encoded credential. Pattern mirrors
|
# otherwise expose the encoded credential.
|
||||||
# pr-autofix-apply.yml.
|
#
|
||||||
|
# `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)"
|
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${PUSH_TOKEN}" | base64 -w0)"
|
||||||
echo "::add-mask::${auth_header}"
|
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
|
# Atomic push of both refs. If either would clobber an existing
|
||||||
# remote ref, the push fails and we stop before npm publish.
|
# remote ref, the push fails and we stop before npm publish.
|
||||||
@@ -656,7 +735,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "vtag=${REF_NAME}" >> "$GITHUB_OUTPUT"
|
echo "vtag=${REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# ── KTD-5: vtag integrity gate ───────────────────────────────────────
|
# ── vtag integrity gate ──────────────────────────────────────────────
|
||||||
# Fail closed before any artifact-producing step (npm publish, Release,
|
# Fail closed before any artifact-producing step (npm publish, Release,
|
||||||
# Docker) runs against an empty or mode-mismatched vtag. Prevents the
|
# Docker) runs against an empty or mode-mismatched vtag. Prevents the
|
||||||
# silent "Release named main" / "Docker tagged from ref fallback"
|
# silent "Release named main" / "Docker tagged from ref fallback"
|
||||||
@@ -668,13 +747,47 @@ jobs:
|
|||||||
MODE: ${{ needs.route.outputs.mode }}
|
MODE: ${{ needs.route.outputs.mode }}
|
||||||
VTAG: ${{ steps.rc-tags.outputs.vtag || steps.stable-vtag.outputs.vtag }}
|
VTAG: ${{ steps.rc-tags.outputs.vtag || steps.stable-vtag.outputs.vtag }}
|
||||||
DRY_RUN: ${{ inputs.dry_run }}
|
DRY_RUN: ${{ inputs.dry_run }}
|
||||||
|
# Available even when the real tag-push step was skipped (rc-version
|
||||||
|
# ran in dry-run too). Lets us build a synthetic vtag to exercise
|
||||||
|
# the regex check without producing artifacts.
|
||||||
|
RC_VERSION_DRY: ${{ steps.rc-version.outputs.rc_version }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [ "$DRY_RUN" = "true" ]; then
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
echo "::notice::dry_run=true — vtag integrity gate is REPORT-ONLY."
|
# Build a synthetic vtag so the regex actually runs in rehearsal
|
||||||
echo "report-only: would have validated VTAG='${VTAG}' for MODE='${MODE}'"
|
# — without this, dry-run never exercises the gate's core check
|
||||||
echo "vtag=${VTAG}" >> "$GITHUB_OUTPUT"
|
# and a regex regression slips through to the first real RC.
|
||||||
|
case "$MODE" in
|
||||||
|
rc) SYNTH_VTAG="v${RC_VERSION_DRY}" ;;
|
||||||
|
stable) SYNTH_VTAG="v0.0.0" ;; # Stable has no dry-run path today; placeholder.
|
||||||
|
*) SYNTH_VTAG="" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "::notice::dry_run=true — vtag integrity gate in synthetic-rehearsal mode."
|
||||||
|
echo "synthetic vtag for ${MODE}: '${SYNTH_VTAG}'"
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
rc)
|
||||||
|
if [[ "$SYNTH_VTAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||||
|
echo "rehearsal: rc regex would accept synthetic vtag ✓"
|
||||||
|
else
|
||||||
|
echo "::warning::rehearsal: rc regex would REJECT synthetic vtag '${SYNTH_VTAG}' — likely a regex regression. Investigate before merging."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
stable)
|
||||||
|
if [[ "$SYNTH_VTAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "rehearsal: stable regex would accept synthetic vtag ✓"
|
||||||
|
else
|
||||||
|
echo "::warning::rehearsal: stable regex would REJECT synthetic vtag '${SYNTH_VTAG}'."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Emit a sentinel rather than empty: prevents downstream future
|
||||||
|
# consumers gating on `vtag != ''` from silently succeeding
|
||||||
|
# against a dry-run state.
|
||||||
|
echo "vtag=DRY_RUN_NO_VTAG" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -775,6 +888,48 @@ jobs:
|
|||||||
steps.rc-tags.outputs.release_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' && inputs.dry_run != 'true' && 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 ───────────────────────────────────────
|
# ── Phase 5 (RC only): Docker images ───────────────────────────────────────
|
||||||
# R6: Docker remains RC-only. Stable Docker builds are explicitly deferred.
|
# R6: Docker remains RC-only. Stable Docker builds are explicitly deferred.
|
||||||
# Secrets are passed explicitly (not via `secrets: inherit`) so the
|
# Secrets are passed explicitly (not via `secrets: inherit`) so the
|
||||||
|
|||||||
+39
-14
@@ -179,32 +179,57 @@ routes between two modes based on the triggering event:
|
|||||||
the npm tarball exactly (traceable releases). The RC tag is excluded
|
the npm tarball exactly (traceable releases). The RC tag is excluded
|
||||||
from this workflow's `push: tags:` filter, so it does **not** re-trigger
|
from this workflow's `push: tags:` filter, so it does **not** re-trigger
|
||||||
publishing — preventing the double-publish failure mode tracked in #1609.
|
publishing — preventing the double-publish failure mode tracked in #1609.
|
||||||
Recovery after a partial failure:
|
Recovery after a partial failure: the workflow's `if: failure()` cleanup
|
||||||
|
step in the `publish` job auto-deletes the v-tag and marker on most
|
||||||
|
post-publish failures, so the typical retry is just:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh workflow run publish.yml --ref main -f force=true
|
||||||
|
# or push a new commit to main, which will cut a fresh RC
|
||||||
|
```
|
||||||
|
|
||||||
|
If auto-cleanup didn't run (e.g. the cleanup step itself failed, or the
|
||||||
|
failure happened in the route/rc-guard phase before the marker was
|
||||||
|
pushed), manual cleanup is:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push --delete origin rc/<HEAD_SHA> v<RC>
|
git push --delete origin rc/<HEAD_SHA> v<RC>
|
||||||
# then redispatch the workflow with force: true
|
# then redispatch with force: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Release-PR-skip subject pattern.** The rc-guard job recognizes a
|
||||||
|
squash-merged release commit by matching the commit subject against
|
||||||
|
`^chore: release vX.Y.Z` (optionally followed by ` (#NNNN)` for the
|
||||||
|
squash-merge PR-number suffix). Match is case-insensitive — `Chore: Release v1.2.3`
|
||||||
|
works too. PRs that should suppress the RC build must either use this
|
||||||
|
subject shape, or carry the `release` label so the label-based fallback
|
||||||
|
fires. Other release-style subjects (`chore(release): v1.2.3`,
|
||||||
|
`release: v1.2.3`) will NOT trigger the skip — please name the release
|
||||||
|
PR exactly `chore: release vX.Y.Z` to keep the dedup deterministic.
|
||||||
|
|
||||||
**Docker-only partial failure:** if `publish` succeeds (npm tarball + tags
|
**Docker-only partial failure:** if `publish` succeeds (npm tarball + tags
|
||||||
are live) but the `docker` job subsequently fails (e.g. GHCR flakiness),
|
are live) but the `docker` job subsequently fails (e.g. GHCR flakiness),
|
||||||
the npm RC is already published and the `rc/<HEAD_SHA>` marker is in place.
|
the npm RC is already published and the `rc/<HEAD_SHA>` marker is in place.
|
||||||
Re-running `publish.yml` with `force: true` will abort at the
|
Recovery without cutting a new RC:
|
||||||
"Version already exists on npm" guard. To recover without cutting a new RC:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Manually trigger only the docker workflow, passing the existing RC tag:
|
# Re-run only the failed docker job from the original workflow run:
|
||||||
gh workflow run docker.yml --ref main -f tag=v<RC_VERSION>
|
gh run rerun <run-id> --failed
|
||||||
# (requires a workflow_dispatch trigger on docker.yml — see note below)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Because `docker.yml` intentionally has no `workflow_dispatch` (images are
|
Find the run ID via `gh run list --workflow=publish.yml --branch main`.
|
||||||
tag-driven by design), the practical recovery options are:
|
`docker.yml` intentionally has no `workflow_dispatch` trigger (images are
|
||||||
- Wait for the next commit on `main`, which will cut a new RC that includes
|
tag-driven by design), so the gh-run-rerun path is the supported recovery.
|
||||||
the Docker build.
|
|
||||||
- Manually run `docker build` + `docker push` locally and sign with Cosign
|
**GitHub Release transient failure** (npm publish succeeded, Release step
|
||||||
against the same digest.
|
failed): the npm artifact is live but no GitHub Release page exists.
|
||||||
- Delete `rc/<HEAD_SHA>` and `v<RC>` tags, then redispatch with `force: true` to re-run the full RC pipeline (cuts a new RC number).
|
Recover by either re-running the failed job (`gh run rerun <run-id> --failed`),
|
||||||
|
or creating the Release manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh release create v<RC> --prerelease --generate-notes # RC
|
||||||
|
gh release create v<X.Y.Z> --notes-file gitnexus/CHANGELOG.md # stable
|
||||||
|
```
|
||||||
|
|
||||||
The rc workflow never moves `latest`. To verify after a change, inspect dist-tags:
|
The rc workflow never moves `latest`. To verify after a change, inspect dist-tags:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user