fix(tracking-mirror): reserve before delete, paginate marker scan, ad… #129
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |