Skip to content

fix(tracking-mirror): reserve before delete, paginate marker scan, ad… #129

fix(tracking-mirror): reserve before delete, paginate marker scan, ad…

fix(tracking-mirror): reserve before delete, paginate marker scan, ad… #129

Workflow file for this run

# Feature-branch release pipeline.
#
# Flow on a push to feat/fix/refactor/perf/revert/* branches:
# 1. ci.yml runs as a called workflow — gates everything below
# 2. semantic-release runs in dev mode (cuts a v<x.y.z>-dev-<sha7>.N
# git tag only — no GitHub release entry and no CHANGELOG bump in
# dev mode; see release.config.mjs)
# 3. docker-build.yml builds and pushes the dev image to Docker Hub —
# only if step 2 published a new pre-release tag
#
# A CI failure means no tag is cut and no image is built. A no-op
# semantic-release run (e.g. only chore/docs commits) means no image.
name: Dev Release
on:
push:
# Restrict to branches that release.config.mjs is configured to
# release from in dev mode. Other prefixes (chore/*, docs/*, ci/*,
# test/*, etc.) would trigger semantic-release with "branch is not
# configured for release" and fail noisily. CI for those branches
# still runs via ci.yml on the PR sync event.
branches:
- "feat/**"
- "fix/**"
- "refactor/**"
- "perf/**"
- "revert/**"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write # semantic-release: push git tag (no GitHub release in dev)
security-events: write
id-token: write
attestations: write # Required by the nested 'merge' job
jobs:
ci:
name: CI
# `head_commit.message` is null on workflow_dispatch and some
# workflow_call invocations; coalesce to '' so contains() doesn't
# behave unexpectedly. Also restrict the skip-ci optimization to
# actual push events.
if: ${{ github.event_name != 'push' ||
!contains(github.event.head_commit.message, 'skip ci') }}
uses: ./.github/workflows/ci.yml
secrets: inherit
semrel-dev:
name: Semantic Release (dev)
needs: ci
runs-on: ubuntu-latest
timeout-minutes: 15
env:
HUSKY: 0
SEMREL_CHANNEL: dev
outputs:
new-release-published: ${{ steps.semrel.outputs.new-release-published }}
new-release-version: ${{ steps.semrel.outputs.new-release-version }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
# Why this step exists:
# dev-release re-runs on every push to a feature branch — including
# force-pushes (rebase, amend, --force-with-lease). semantic-release
# computes the next pre-release counter by walking tags *reachable
# from HEAD*; an orphaned tag (one pointing at a commit that was
# force-pushed away) is invisible to that walk, so semantic-release
# picks the SAME version number again and the subsequent `git tag
# <name>` (semantic-release/lib/git.js does NOT pass `-f`) fails
# with `fatal: tag already exists`. Without this cleanup, any
# rebase/amend on a feature branch wedges the dev pipeline until
# the stale tag is manually deleted.
#
# Dev tags are ephemeral by contract — release.config.mjs publishes
# NO GitHub Release and NO CHANGELOG entry in dev mode, so nothing
# downstream pins to a specific `.N`. Resetting the counter on each
# force-push is correct semantics, not data loss.
#
# github.ref_name is normalised to a branch slug via env (defense
# in depth — ref_name is already constrained by Git ref grammar).
- name: Clean stale dev pre-release tags for this branch
env:
BRANCH_REF: ${{ github.ref_name }}
run: |
set -euo pipefail
# Slug derivation must match release.config.mjs PRERELEASE_TEMPLATE
# (`name.replace(/[^a-zA-Z0-9-]/g, '-')`).
BRANCH_SLUG=$(printf '%s' "$BRANCH_REF" | sed 's/[^a-zA-Z0-9-]/-/g')
# `git tag -l <glob>` matches via fnmatch(3), where `*` matches
# any characters including hyphens with backtracking. For slug
# `fix-test`, the glob `v*-fix-test.*` would over-match the
# unrelated tag `v0.4.0-fix-some-other-fix-test.1`, deleting
# another branch's dev tags. Use an anchored ERE instead so
# only this slug's dev pre-release tags match. BRANCH_SLUG is
# already sanitised to `[a-zA-Z0-9-]` above (sed strips every-
# thing else), so it is safe to interpolate without regex
# escaping.
STALE_TAGS=$(git tag -l | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+-${BRANCH_SLUG}\.[0-9]+$" || true)
if [ -z "$STALE_TAGS" ]; then
echo "No stale dev pre-release tags for branch '${BRANCH_SLUG}'"
exit 0
fi
echo "Deleting stale dev pre-release tags for branch '${BRANCH_SLUG}':"
echo "$STALE_TAGS"
# Local delete is the load-bearing one (semantic-release fails
# on the local `git tag` call). Origin delete is best-effort
# so a tag already cleaned up out-of-band does not wedge CI.
echo "$STALE_TAGS" | xargs git tag -d
while IFS= read -r tag; do
[ -z "$tag" ] && continue
git push origin --delete "$tag" || echo "warn: origin delete failed for ${tag}"
done <<< "$STALE_TAGS"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: .tool-versions
- name: Setup Node.js for semantic-release
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Cache Bun install dir
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build project
run: bun run build
- name: Install semantic-release packages
# Pinned to exact versions (not `@^x.y.z`) because --no-save
# --no-package-lock means each rerun would otherwise resolve a
# potentially different minor/patch. Deterministic pins keep the
# release pipeline reproducible; bump them in a dedicated PR.
run: |
npm install --no-save --no-package-lock \
semantic-release@25.0.3 \
@semantic-release/changelog@6.0.3 \
@semantic-release/commit-analyzer@13.0.1 \
@semantic-release/git@10.0.1 \
@semantic-release/github@12.0.3 \
@semantic-release/npm@13.1.3 \
@semantic-release/release-notes-generator@14.1.0 \
@semantic-release/exec@7.1.0
- name: Run semantic-release (dev)
id: semrel
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
npx semantic-release
if [ -f "RELEASE_VERSION" ]; then
VERSION=$(cat RELEASE_VERSION)
echo "new-release-published=true" >> "$GITHUB_OUTPUT"
echo "new-release-version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Pre-release published: v$VERSION"
else
echo "new-release-published=false" >> "$GITHUB_OUTPUT"
echo "No new pre-release published"
fi
docker:
name: Docker
needs: semrel-dev
if: needs.semrel-dev.outputs.new-release-published == 'true'
uses: ./.github/workflows/docker-build.yml
with:
tag-name: "v${{ needs.semrel-dev.outputs.new-release-version }}"
is-dev-release: true
secrets: inherit