Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 86 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@ name: CI
# configured on npm (ci.yml). CI on PRs, publish on push to main (stable),
# v*-beta* tag push (beta prerelease), or manual dispatch (staging beta).
#
# Stable version comes from the npm registry (last published `latest` +
# patch), not from a possibly-stale package.json, so repeated merges never
# collide. An intentional minor/major bump in package.json is honored: if the
# working tree version is ahead of the registry, it is published as-is.
# Stable version = max(newest stable `vX.Y.Z` tag, registry `latest`) + patch,
# not a possibly-stale package.json, so repeated merges never collide. Two
# sources because each covers the other's failure: the registry's CDN read lags
# a fresh publish by a minute or two, and a publish can succeed while its tag
# push fails. An intentional minor/major bump in package.json is honored: if
# the working tree version is ahead of both, it is published as-is.
#
# npm trusted publishing (OIDC) auto-generates provenance — no token needed.
#
# Publish failures are retried 3x for transient issues only — a version
# conflict (already published) is NEVER retried. Publish serialized via
# concurrency; CI runs on the same ref are cancelled when superseded.
# Publish failures are retried up to 5x. A version conflict is not retried
# as-is: on `latest` CI bumps a patch and republishes (bounded at two bumps);
# on the beta channels it fails fast, because those versions are already
# unique. Publish is serialized via concurrency; CI runs on the same ref are
# cancelled when superseded.

on:
push:
Expand Down Expand Up @@ -53,6 +57,11 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
# The version baseline reads the newest stable `v*` tag, so the
# publish job needs every tag, not just the ones on this shallow
# commit.
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 24
Expand Down Expand Up @@ -83,13 +92,45 @@ jobs:
fi
echo "tag=beta" >> "$GITHUB_OUTPUT"
else
# merge to main -> next patch after the last published `latest`
LAST=$(npm view opencode-rtk-plugin@latest version 2>/dev/null || true)
# merge to main -> next patch after the last published release.
#
# The baseline is the HIGHEST of two sources:
# - the newest stable `vX.Y.Z` tag, which the publish job pushes
# immediately after a successful publish; and
# - the registry's `latest`.
#
# Registry reads go through a CDN with a couple of minutes of
# cache, so a second shipping merge inside that window still sees
# the PREVIOUS version and computes one that is already published
# (that is the E409 race). The tag is refreshed with the push and
# does not lag, so it wins there. The registry is still consulted
# because it also covers the reverse failure: a publish that
# succeeded while its tag push did not.
#
# `git tag --list` (not `git describe`) is deliberate: describe
# reports the newest tag REACHABLE from HEAD, so a tag created on
# a branch that was later rewound would be missed. Both the tag
# list and the registry are read as an unordered set of candidate
# baselines; only the numeric maximum matters.
#
# Only plain `vX.Y.Z` tags count. `sort -V` is not semver-aware
# (it sorts `1.0.0-rc.1` above `1.0.0` and any word above a
# number), and this job only ever publishes plain versions, so
# pre-release and non-numeric tags are excluded by the pattern
# rather than trusted to sort correctly. `git tag` has no
# --exclude, so this is a filter, not a flag.
LAST_TAG=$(git tag --list 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sed 's/^v//' | sort -V | tail -n1 || true)
LAST_REG=$(npm view opencode-rtk-plugin@latest version 2>/dev/null || true)
if [ -z "$LAST_REG" ]; then
echo "::warning::could not read the registry's latest version; deriving from tags only"
fi
LAST=$(printf '%s\n%s\n' "$LAST_TAG" "$LAST_REG" | sed '/^$/d' | sort -V | tail -n1)
CUR=$(npm pkg get version | tr -d '"')
echo "Baseline candidates: tag=${LAST_TAG:-none} registry=${LAST_REG:-none} -> ${LAST:-none}"
if [ -n "$LAST" ] && [ "$(printf '%s\n%s' "$CUR" "$LAST" | sort -V | tail -1)" = "$LAST" ]; then
# tree at/below registry -> align to registry (unless equal),
# then bump patch; npm errors on "Version not changed" if the
# align target equals the current version
# tree at/below the baseline -> align to it (unless equal), then
# bump patch; npm errors on "Version not changed" if the align
# target equals the current version
if [ "$CUR" != "$LAST" ]; then
npm version "$LAST" --no-git-tag-version --no-commit-hooks
fi
Expand All @@ -100,20 +141,45 @@ jobs:
fi
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi
- name: Show publishing target
run: 'echo "Publishing $(npm pkg get version) to tag ${{ steps.channel.outputs.tag }}"'
- name: Publish (retry transient only)
- name: Show derived publishing target
run: 'echo "Derived target: $(npm pkg get version) to tag ${{ steps.channel.outputs.tag }} (a version conflict bumps the patch and republishes)"'
- name: Publish (bump on version conflict, retry transient)
timeout-minutes: 5
run: |
for i in $(seq 1 3); do
OUT=$(npm publish --tag ${{ steps.channel.outputs.tag }} --provenance --access public 2>&1) && { echo "$OUT"; exit 0; }
if echo "$OUT" | grep -qE "cannot publish over|previously published"; then
# A version conflict cannot be retried as-is: npm keeps rejecting the
# same version. On the `latest` channel a conflict means the registry
# is ahead of the derived baseline (CDN lag, or a publish from a
# parallel run), so bump a patch and try again — the tag pushed below
# is read from package.json, so it follows the bump. Every other
# failure is retried as a transient network/registry error. The beta
# channels derive unique versions, so a conflict there is a re-run
# and still fails fast.
#
# npm prints "Cannot publish over ..." and "npm error code E409"; the
# match is case-insensitive on purpose.
bumps=0
attempts=0
while [ "$attempts" -lt 5 ]; do
attempts=$((attempts + 1))
if OUT=$(npm publish --tag ${{ steps.channel.outputs.tag }} --provenance --access public 2>&1); then
echo "$OUT"
echo "Published $(npm pkg get version | tr -d '"')"
exit 0
fi
if echo "$OUT" | grep -qiE "cannot publish over|previously published|previously staged|npm error code E409"; then
if [ "${{ steps.channel.outputs.tag }}" = "latest" ] && [ "$bumps" -lt 2 ]; then
bumps=$((bumps + 1))
echo "$OUT" >&2
echo "Version conflict — bumping patch and retrying ($bumps/2)" >&2
npm version patch --no-git-tag-version --no-commit-hooks
continue
fi
echo "$OUT" >&2
echo "Version conflict — not retrying" >&2
exit 1
fi
echo "$OUT"
echo "Publish failed (transient?), retrying ($i/3)..."
echo "Publish failed (transient?), retrying ($attempts/5)..."
sleep 3
done
exit 1
Expand Down Expand Up @@ -178,7 +244,7 @@ jobs:
run: |
for i in $(seq 1 3); do
OUT=$(npm publish --tag beta --provenance --access public 2>&1) && { echo "$OUT"; exit 0; }
if echo "$OUT" | grep -qE "cannot publish over|previously published"; then
if echo "$OUT" | grep -qiE "cannot publish over|previously published|previously staged|npm error code E409"; then
echo "$OUT" >&2
echo "Version conflict (workflow re-run) — deduped" >&2
exit 0
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,11 @@ publisher per package; the workflow filename must stay `ci.yml`):

- `test` — lint + plugin-export smoke check on `main` and PRs (Node 24).
- push to `main` → `publish` job publishes the next patch to `latest`, pushes a
`vX.Y.Z` tag, and opens a GitHub release. The version is derived from the
last published `latest`, so repeated merges never collide; an intentional
minor/major bump in `package.json` is honored.
`vX.Y.Z` tag, and opens a GitHub release. The version is the highest of the
newest stable `vX.Y.Z` tag and the registry's `latest`, plus a patch, so
repeated merges never collide; an intentional minor/major bump in
`package.json` is honored. A publish that hits `E409` bumps a patch and
retries (bounded), because the registry read can lag a fresh publish.
- tag `v*-beta*` → `publish` job publishes `beta` + a prerelease GitHub release.
- every PR push → `prerelease` job publishes `<version>-beta.<run_number>` to
`beta` (same-repo, non-draft, non-dependabot PRs; re-runs deduped).
Expand Down
Loading