Files
millionco__react-doctor/.github/workflows/action-version-bump.yml
devin-ai-integration[bot] 4728f2da91 FABLE_CLEANUP: fix(action): 20-day audit fixes for the GitHub Action and workflows (#1015)
Co-authored-by: Aiden Bai <aiden.bai05@gmail.com>
Co-authored-by: Rayhan Noufal Arayilakath <me@rayhanadev.com>
2026-07-02 01:29:50 -07:00

178 lines
8.5 KiB
YAML

name: Action Version Bump
# Guards the GitHub Action's independent release cadence (AGENTS.md → "GitHub
# Action versioning"). When a change touches the action's release surface
# (action.yml + the scripts it shells out to), this either recommends a version
# bump on the PR or — opt-in — performs it on merge to main.
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, edited]
push:
branches: [main]
permissions:
contents: read
jobs:
recommend:
name: Recommend bump on PR
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
timeout-minutes: 5
# A newer push to the same PR obsoletes the in-flight recommendation.
concurrency:
group: action-version-bump-recommend-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
fetch-depth: 0
fetch-tags: true
- name: Detect action changes and compute recommended bump
id: detect
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
ACTION_COMMIT_SUBJECT: ${{ github.event.pull_request.title }}
run: |
# Assign separately from `export` so errexit sees a failing git diff
# instead of silently treating it as "no files changed".
changed_files="$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}")"
all_tags="$(git tag --list 'v*')"
export ACTION_CHANGED_FILES="${changed_files}"
export ACTION_ALL_TAGS="${all_tags}"
node scripts/recommend-action-version-bump.mjs "${RUNNER_TEMP}/action-version-comment.md"
- name: Upsert recommendation comment
uses: actions/github-script@v7
env:
ACTION_CHANGED: ${{ steps.detect.outputs.changed }}
COMMENT_FILE: ${{ runner.temp }}/action-version-comment.md
with:
script: |
const fs = require("node:fs");
const marker = "<!-- react-doctor:action-version -->";
const { owner, repo } = context.repo;
const issueNumber = context.payload.pull_request.number;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
});
const existing = comments.find((comment) => comment.body?.includes(marker));
const actionChanged = process.env.ACTION_CHANGED === "true";
// Read outside the try: a missing comment file when the detect step
// reported changed=true is a contract violation, not a token issue,
// and should red the check.
const body = actionChanged ? fs.readFileSync(process.env.COMMENT_FILE, "utf8") : "";
// Comment writes are best-effort: on a fork PR the GITHUB_TOKEN is
// read-only regardless of the declared permissions, and a 403 here
// must not red the check for an external contributor.
try {
if (!actionChanged) {
// The PR no longer touches the action surface — clear a stale recommendation.
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
}
return;
}
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body });
}
} catch (error) {
core.warning(`Could not upsert the action-version comment: ${error.message}`);
}
bump:
name: Perform bump on merge
# Opt-in: set the repo variable AUTO_BUMP_ACTION_TAG=true to auto-tag.
# Otherwise this is a no-op and the maintainer cuts the GPG-signed tag by
# hand using the commands from the PR recommendation.
if: ${{ github.event_name == 'push' && vars.AUTO_BUMP_ACTION_TAG == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 5
# Serialize bump runs, with `queue: max` so a superseded PENDING run stays
# queued instead of canceled — under the default single pending slot, the
# middle of three quick merges would never be judged for a tag (an untagged
# action release). Queued runs are NOT guaranteed to execute in order, so
# the tag step below refuses to move the floating major backwards.
# `queue: max` requires cancel-in-progress to be false/absent.
concurrency:
group: action-version-bump-main
queue: max
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
fetch-tags: true
- name: Detect action changes and compute recommended bump
id: detect
env:
GH_TOKEN: ${{ github.token }}
BEFORE_SHA: ${{ github.event.before }}
HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: |
base="${BEFORE_SHA}"
if [ -z "${base}" ] || [ "${base}" = "0000000000000000000000000000000000000000" ]; then
# New branch / first push: diff against HEAD's parent, or the empty
# tree for a root commit with no parent (so its files still count as
# changed instead of diffing HEAD against itself).
base="$(git rev-parse HEAD^ 2>/dev/null || git hash-object -t tree /dev/null)"
fi
# Classify from the merged PR's title — the same authoritative signal the
# recommend job uses — so a non-conventional merge subject (e.g.
# "Merge pull request #N") can't silently downgrade the bump to a patch.
# Fall back to the commit subject for a direct push to main.
pr_title="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" --jq '.[0].title // ""' 2>/dev/null || true)"
if [ -n "${pr_title}" ]; then
export ACTION_COMMIT_SUBJECT="${pr_title}"
else
export ACTION_COMMIT_SUBJECT="$(printf '%s' "${HEAD_COMMIT_MESSAGE}" | head -n1)"
fi
# Assign separately from `export` so errexit sees a failing git diff
# (e.g. an unreachable `github.event.before` after a force-push)
# instead of silently treating it as "no files changed".
changed_files="$(git diff --name-only "${base}" "${GITHUB_SHA}")"
all_tags="$(git tag --list 'v*')"
export ACTION_CHANGED_FILES="${changed_files}"
export ACTION_ALL_TAGS="${all_tags}"
node scripts/recommend-action-version-bump.mjs
- name: Create and push action release tags
if: ${{ steps.detect.outputs.changed == 'true' && steps.detect.outputs['tag-exists'] != 'true' }}
env:
NEXT_TAG: ${{ steps.detect.outputs.next }}
MAJOR_TAG: ${{ steps.detect.outputs.major }}
BUMP_LEVEL: ${{ steps.detect.outputs.level }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# CI has no signing key, so override the repo's tag.gpgsign default and
# create an unsigned annotated tag instead of failing on a missing key.
git -c tag.gpgsign=false tag -a "${NEXT_TAG}" -m "react-doctor action ${NEXT_TAG} (${BUMP_LEVEL})"
git push origin "${NEXT_TAG}"
# `queue: max` serializes runs but does not order them: an older
# commit's run can execute after a newer commit already tagged and
# moved the floating major forward. Never move it backwards.
if git rev-parse --quiet --verify "refs/tags/${MAJOR_TAG}" >/dev/null \
&& ! git merge-base --is-ancestor "${MAJOR_TAG}^{commit}" "${GITHUB_SHA}"; then
echo "::warning::${MAJOR_TAG} points at a commit that is not an ancestor of ${GITHUB_SHA} (out-of-order bump runs); tagged ${NEXT_TAG} without moving ${MAJOR_TAG}."
echo "Tagged ${NEXT_TAG}; left ${MAJOR_TAG} in place (a newer commit already carries it)." >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
git -c tag.gpgsign=false tag -fa "${MAJOR_TAG}" -m "react-doctor action ${MAJOR_TAG} (floating major -> ${NEXT_TAG})"
git push --force origin "${MAJOR_TAG}"
echo "Tagged ${NEXT_TAG} and moved ${MAJOR_TAG} to ${GITHUB_SHA}." >> "${GITHUB_STEP_SUMMARY}"