fix(release): make the --local scan clone see the repointed release b… #68
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
| name: Release Please | |
| on: | |
| push: | |
| branches: | |
| - next | |
| - main | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| concurrency: | |
| group: release-please-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| release-please: | |
| if: github.repository == 'team-telnyx/telnyx-cli' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.SDK_WRITE_TOKEN }} | |
| - name: Set up Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - name: Install release-please | |
| run: npm install --no-save release-please@17 | |
| - name: Read Release-As version from next branch | |
| if: github.ref == 'refs/heads/next' | |
| id: version | |
| run: | | |
| VERSION="" | |
| for commit in $(git rev-list HEAD --max-count=50); do | |
| BODY=$(git log -1 --format=%b "$commit") | |
| RA=$(echo "$BODY" | grep -oP 'Release-As:\s*\K[\d.]+' | head -1 || true) | |
| if [ -n "$RA" ]; then | |
| VERSION="$RA" | |
| break | |
| fi | |
| done | |
| if [ -z "$VERSION" ]; then | |
| echo "::warning::No Release-As found; release-please will use semantic versioning" | |
| echo "has_version=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "has_version=true" >> "$GITHUB_OUTPUT" | |
| echo "Release-As version: $VERSION" | |
| fi | |
| - name: Run release-please (release-pr) | |
| if: github.ref == 'refs/heads/next' | |
| id: release-pr | |
| run: | | |
| ARGS=( | |
| release-pr | |
| --repo-url "${{ github.repositoryUrl }}" | |
| --token "${{ secrets.SDK_WRITE_TOKEN }}" | |
| --target-branch main | |
| --config-file release-please-config.json | |
| --manifest-file .release-please-manifest.json | |
| --local | |
| ) | |
| if [ "${{ steps.version.outputs.has_version }}" = "true" ]; then | |
| ARGS+=(--release-as "${{ steps.version.outputs.version }}") | |
| fi | |
| # Widen the --local commit scan to next's promote commits: release-pr | |
| # collects commits since the last release on the TARGET branch (main), | |
| # so with only hidden ci:-typed commits on main it silently skips | |
| # (releases previously depended on coincidental fix-typed hotfixes). | |
| # next always contains main (the promote enforces ancestry), so | |
| # pointing the local refs at HEAD only widens the scan; the PR is | |
| # still created against main via the API. | |
| git branch -f main HEAD 2>/dev/null || true | |
| git update-ref refs/remotes/origin/main HEAD | |
| # --local mode clones from --repo-url — a FRESH GitHub clone whose | |
| # refs ignore the repoint above (it only rewrites this workspace), so | |
| # the scan saw 0 commits on a quiet master and silently skipped the | |
| # release PR (2026-07-09). Rewrite the URL to this workspace so the | |
| # CLI clones the repointed refs; API PR-creation still parses | |
| # owner/repo from the URL string. | |
| git config --global url."file://${GITHUB_WORKSPACE}".insteadOf "${{ github.repositoryUrl }}" | |
| OUTPUT=$(npx release-please "${ARGS[@]}" 2>&1) || true | |
| echo "$OUTPUT" | |
| PR_URL=$(echo "$OUTPUT" | grep -oP 'https://(?:api\.)?github\.com/(?:repos/[^/]+/[^/]+/)?pulls?/\d+' | head -1) | |
| if [ -n "$PR_URL" ]; then | |
| PR_NUM=$(echo "$PR_URL" | grep -oP '\d+$') | |
| echo "pr_number=$PR_NUM" >> "$GITHUB_OUTPUT" | |
| echo "prs_created=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "prs_created=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Ensure release PR carries autorelease label (self-heal) | |
| if: github.ref == 'refs/heads/next' | |
| env: | |
| GH_TOKEN: ${{ secrets.SDK_WRITE_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| # release-please applies `autorelease: pending` in a separate API call | |
| # AFTER opening the PR; a failure between the two is swallowed by the | |
| # `|| true` above and leaves the PR unlabeled. The label is how the | |
| # sync fallback, the auto-merge fallback, AND github-release (after | |
| # merge) find release PRs — an unlabeled one merges but never tags | |
| # (ghost release). Re-assert the label on any open release-please PR. | |
| for pr in $(gh pr list --repo "$REPO" --state open --json number,headRefName,labels \ | |
| --jq '.[] | select(.headRefName | startswith("release-please--")) | |
| | select(([.labels[].name] | index("autorelease: pending")) | not) | |
| | .number'); do | |
| gh pr edit "$pr" --repo "$REPO" --add-label "autorelease: pending" \ | |
| && echo "Re-added missing autorelease label to PR #$pr" \ | |
| || echo "::warning::Failed to add autorelease label to PR #$pr" | |
| done | |
| - name: Sync next code into release PR (tree replacement, not merge) | |
| if: github.ref == 'refs/heads/next' | |
| env: | |
| GH_TOKEN: ${{ secrets.SDK_WRITE_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| PR_NUM: ${{ steps.release-pr.outputs.pr_number }} | |
| run: | | |
| # release-please creates a PR with only version bumps (CHANGELOG, | |
| # version.go, README, CHANGELOG.md, .release-please-manifest.json). | |
| # The actual SDK code changes are on `next` but NOT included in the | |
| # release PR. This step syncs `next` code into the release PR branch. | |
| # | |
| # CRITICAL: We use tree replacement (checkout + restore version files) | |
| # instead of git merge. A 3-way merge produces duplicate method | |
| # implementations when staging and prod have different method ordering | |
| # (git sees them as non-conflicting additions and keeps both copies). | |
| # Tree replacement avoids this entirely — the code tree comes from | |
| # `next` as-is, and only the version-bump files from release-please | |
| # are preserved on top. | |
| PR_BRANCH="" | |
| if [ -n "$PR_NUM" ]; then | |
| sleep 3 # brief propagation delay | |
| PR_BRANCH=$(gh pr view "$PR_NUM" --repo "$REPO" --json headRefName --jq '.headRefName' 2>/dev/null || true) | |
| fi | |
| # Fallback: query by label with retry | |
| if [ -z "$PR_BRANCH" ]; then | |
| for i in 1 2 3; do | |
| PR_BRANCH=$(gh pr list --repo "$REPO" --state open --label "autorelease: pending" --json headRefName --jq '.[0].headRefName' 2>/dev/null || true) | |
| if [ -n "$PR_BRANCH" ]; then break; fi | |
| echo "PR not found yet, retrying in 5s (attempt $i)..." | |
| sleep 5 | |
| done | |
| fi | |
| if [ -z "$PR_BRANCH" ]; then | |
| echo "::warning::No open release PR found after retries, skipping sync" | |
| exit 0 | |
| fi | |
| echo "Found release PR branch: $PR_BRANCH" | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git fetch origin "$PR_BRANCH" next | |
| git checkout -B "$PR_BRANCH" "origin/$PR_BRANCH" | |
| # Save version-bump files from the release PR branch (release-please output) | |
| STASH_DIR=$(mktemp -d) | |
| for f in CHANGELOG.md pkg/cmd/version.go README.md .release-please-manifest.json; do | |
| if [ -f "$f" ]; then | |
| mkdir -p "$STASH_DIR/$(dirname "$f")" | |
| cp "$f" "$STASH_DIR/$f" | |
| fi | |
| done | |
| # Replace entire working tree from next (exact tree, no merge) | |
| # git rm handles deletions (files removed in next but still in this branch) | |
| # git clean removes untracked files (node_modules created by npx release-please) | |
| git reset --hard origin/next | |
| git clean -fdx | |
| # Restore version-bump files from the release PR branch | |
| for f in CHANGELOG.md pkg/cmd/version.go README.md .release-please-manifest.json; do | |
| if [ -f "$STASH_DIR/$f" ]; then | |
| mkdir -p "$(dirname "$f")" | |
| cp "$STASH_DIR/$f" "$f" | |
| git add "$f" | |
| fi | |
| done | |
| rm -rf "$STASH_DIR" | |
| # Commit if there are changes | |
| if git diff --cached --quiet; then | |
| echo "No changes after syncing next code" | |
| else | |
| git commit -m "chore: sync code from next into release PR" | |
| git push --force origin "$PR_BRANCH" | |
| echo "Successfully synced next code into release PR branch (tree replacement)" | |
| fi | |
| - name: Run release-please (github-release) | |
| if: github.ref == 'refs/heads/main' | |
| run: | | |
| npx release-please github-release \ | |
| --repo-url "${{ github.repositoryUrl }}" \ | |
| --token "${{ secrets.SDK_WRITE_TOKEN }}" \ | |
| --target-branch main \ | |
| --config-file release-please-config.json \ | |
| --manifest-file .release-please-manifest.json \ | |
| 2>&1 | |
| - name: Verify release commit produced a tag (ghost-release guard) | |
| if: github.ref == 'refs/heads/main' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| # `github-release` exits 0 even when it finds nothing to release ([]). | |
| # If the commit that triggered this run is a merged `release: X.Y.Z` | |
| # commit, a missing vX.Y.Z tag here means it silently no-opped — | |
| # usually a merged release PR that lost its `autorelease: pending` | |
| # label. Fail loud instead of ghosting the release. | |
| MSG=$(git log -1 --format=%s "$GITHUB_SHA") | |
| VER=$(echo "$MSG" | grep -oP '^release:\s*\K[0-9][0-9.]*' || true) | |
| if [ -z "$VER" ]; then | |
| echo "Not a release commit; nothing to verify" | |
| exit 0 | |
| fi | |
| if git ls-remote --exit-code --tags origin "v$VER" >/dev/null 2>&1; then | |
| if ! gh api "repos/${{ github.repository }}/releases/tags/v$VER" >/dev/null 2>&1; then | |
| echo "::error::Tag v$VER exists but there is no GitHub Release for it - the publish workflow (release: published) will never fire. Create it: gh release create v$VER --generate-notes" | |
| exit 1 | |
| fi | |
| echo "Tag v$VER and its GitHub Release exist - release verified" | |
| else | |
| echo "::error::Merged release commit for $VER but no v$VER tag exists. github-release silently no-opped (merged release PR likely missing the autorelease pending label). Add the label to the merged PR and re-run this workflow." | |
| exit 1 | |
| fi |