From 9b65b169ccef9a8d9e7cb78876c509265bef1862 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Thu, 25 Jun 2026 14:29:24 +0800 Subject: [PATCH] ci: keep release action chain recoverable Release automation used brittle branch filters and a disconnected post-release dispatch path. This keeps the VTable-style dispatch chain and makes recovery explicit and guarded. Constraint: GitHub branch filters use glob syntax, not semver regex Constraint: No new dependencies; reuse js-yaml Rejected: workflow_run orchestration | Existing flow uses repository_dispatch Rejected: single-package npm check | rush publish --include-all can partially publish Confidence: high Scope-risk: moderate Directive: Keep post-release wired to develop-synced or replace the consumer Tested: node common/scripts/verify-release-workflows.js Tested: node --check common/scripts/verify-release-workflows.js Tested: js-yaml parse of all workflow yml files Tested: git diff --check Not-tested: Live Actions run, npm publish, tag creation, GitHub Release creation --- .github/workflows/develop-synced-dispatch.yml | 38 ++-- .github/workflows/post-release.yml | 63 +++--- .github/workflows/release.yml | 188 +++++++++++++++++- .github/workflows/sync-main-to-develop.yml | 35 +++- common/scripts/verify-release-workflows.js | 148 ++++++++++++++ 5 files changed, 419 insertions(+), 53 deletions(-) create mode 100644 common/scripts/verify-release-workflows.js diff --git a/.github/workflows/develop-synced-dispatch.yml b/.github/workflows/develop-synced-dispatch.yml index d22ff96e5a..2c4d6eab21 100644 --- a/.github/workflows/develop-synced-dispatch.yml +++ b/.github/workflows/develop-synced-dispatch.yml @@ -1,6 +1,12 @@ name: Dispatch develop-synced after sync/main merged (Scheme A) on: + workflow_dispatch: + inputs: + version: + description: 'Release version, e.g. 1.2.3' + required: true + type: string pull_request: types: - closed @@ -9,7 +15,9 @@ on: jobs: dispatch_develop_synced: - if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'sync/main-') + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'sync/main-')) runs-on: ubuntu-latest @@ -17,17 +25,23 @@ jobs: contents: write steps: - - name: Derive version from sync branch + - name: Resolve version from input or sync branch id: version + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | set -euo pipefail - BRANCH="${{ github.event.pull_request.head.ref }}" - echo "Head branch: ${BRANCH}" - VERSION="${BRANCH#sync/main-}" - if [ -z "${VERSION}" ] || [ "${VERSION}" = "${BRANCH}" ]; then - echo "Failed to parse version from branch ${BRANCH}, skip dispatch." - echo "version=" >> "$GITHUB_OUTPUT" - exit 0 + VERSION="${INPUT_VERSION:-}" + if [ -z "${VERSION}" ]; then + BRANCH="${PR_HEAD_REF}" + echo "Head branch: ${BRANCH}" + VERSION="${BRANCH#sync/main-}" + if [ -z "${VERSION}" ] || [ "${VERSION}" = "${BRANCH}" ]; then + echo "Failed to parse version from branch ${BRANCH}, skip dispatch." + echo "version=" >> "$GITHUB_OUTPUT" + exit 0 + fi fi echo "Parsed version: ${VERSION}" echo "version=${VERSION}" >> "$GITHUB_OUTPUT" @@ -35,7 +49,7 @@ jobs: - name: Send repository_dispatch develop-synced if: steps.version.outputs.version != '' env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.CREATE_TAG_RELEASE_TOKEN || github.token }} run: | set -euo pipefail VERSION="${{ steps.version.outputs.version }}" @@ -43,8 +57,8 @@ jobs: echo "Sending repository_dispatch develop-synced for version ${VERSION} to ${OWNER_REPO}" - curl -X POST \ + curl --fail-with-body -sS -X POST \ -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ "https://api.github.com/repos/${OWNER_REPO}/dispatches" \ -d "{\"event_type\":\"develop-synced\",\"client_payload\":{\"version\":\"${VERSION}\"}}" diff --git a/.github/workflows/post-release.yml b/.github/workflows/post-release.yml index a51fd1bcaf..162c9627ef 100644 --- a/.github/workflows/post-release.yml +++ b/.github/workflows/post-release.yml @@ -1,6 +1,8 @@ name: Post release after develop synced on: + repository_dispatch: + types: [develop-synced] workflow_dispatch: inputs: version: @@ -16,19 +18,21 @@ jobs: contents: write steps: - - name: Read version from workflow_dispatch input + - name: Read version from event payload or workflow_dispatch input id: meta env: + PAYLOAD_VERSION: ${{ github.event.client_payload.version }} INPUT_VERSION: ${{ github.event.inputs.version }} run: | set -euo pipefail - if [ -z "${INPUT_VERSION}" ]; then - echo "No version in workflow_dispatch input, skip post-release." + VERSION="${PAYLOAD_VERSION:-${INPUT_VERSION:-}}" + if [ -z "${VERSION}" ]; then + echo "No version in repository_dispatch payload or workflow_dispatch input, skip post-release." echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi - echo "Using version from input: ${INPUT_VERSION}" - echo "version=${INPUT_VERSION}" >> "$GITHUB_OUTPUT" + echo "Using version: ${VERSION}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "skip=false" >> "$GITHUB_OUTPUT" - name: Checkout main @@ -39,14 +43,13 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Configure git remote to use PAT + - name: Configure git remote for tag push if: steps.meta.outputs.skip != 'true' env: - GH_PAT: ${{ secrets.CREATE_TAG_RELEASE_TOKEN }} + GH_TOKEN: ${{ secrets.CREATE_TAG_RELEASE_TOKEN || github.token }} run: | set -euo pipefail - git remote set-url origin "https://x-access-token:${GH_PAT}@github.com/${GITHUB_REPOSITORY}.git" - git remote -v + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - name: Fetch tags if: steps.meta.outputs.skip != 'true' @@ -59,24 +62,29 @@ jobs: if: steps.meta.outputs.skip != 'true' env: VERSION: ${{ steps.meta.outputs.version }} - GH_TOKEN: ${{ secrets.CREATE_TAG_RELEASE_TOKEN }} + GH_TOKEN: ${{ secrets.CREATE_TAG_RELEASE_TOKEN || github.token }} run: | set -euo pipefail TAG="v${VERSION}" + TAG_EXISTS="false" + RELEASE_EXISTS="false" if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then - echo "Tag ${TAG} already exists, skip post-release." - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 + echo "Tag ${TAG} already exists." + TAG_EXISTS="true" fi if gh release view "${TAG}" >/dev/null 2>&1; then echo "Release ${TAG} already exists, skip post-release." + RELEASE_EXISTS="true" echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 + else + echo "Release ${TAG} does not exist, continue post-release." + echo "skip=false" >> "$GITHUB_OUTPUT" fi - echo "skip=false" >> "$GITHUB_OUTPUT" + echo "tag_exists=${TAG_EXISTS}" >> "$GITHUB_OUTPUT" + echo "release_exists=${RELEASE_EXISTS}" >> "$GITHUB_OUTPUT" - name: Ensure main changelog exists if: steps.meta.outputs.skip != 'true' && steps.exist.outputs.skip != 'true' @@ -161,15 +169,15 @@ jobs: - name: Verify gh identity if: steps.meta.outputs.skip != 'true' && steps.exist.outputs.skip != 'true' && steps.body.outputs.has_body == 'true' env: - GH_TOKEN: ${{ secrets.CREATE_TAG_RELEASE_TOKEN }} + GH_TOKEN: ${{ secrets.CREATE_TAG_RELEASE_TOKEN || github.token }} run: | set -euo pipefail gh api user -q '.login' - - name: Diagnose PAT repository permission + - name: Diagnose repository token permission if: steps.meta.outputs.skip != 'true' && steps.exist.outputs.skip != 'true' && steps.body.outputs.has_body == 'true' env: - GH_TOKEN: ${{ secrets.CREATE_TAG_RELEASE_TOKEN }} + GH_TOKEN: ${{ secrets.CREATE_TAG_RELEASE_TOKEN || github.token }} run: | set -euo pipefail LOGIN=$(gh api user -q '.login') @@ -188,21 +196,26 @@ jobs: echo "permission insufficient: $RESP" fi - - name: Create tag and GitHub Release + - name: Create missing tag and GitHub Release if: steps.meta.outputs.skip != 'true' && steps.exist.outputs.skip != 'true' && steps.body.outputs.has_body == 'true' env: VERSION: ${{ steps.meta.outputs.version }} - GH_TOKEN: ${{ secrets.CREATE_TAG_RELEASE_TOKEN }} + GH_TOKEN: ${{ secrets.CREATE_TAG_RELEASE_TOKEN || github.token }} + TAG_EXISTS: ${{ steps.exist.outputs.tag_exists }} run: | set -euo pipefail TAG="v${VERSION}" - git fetch origin main:refs/remotes/origin/main --depth=1 - MAIN_SHA="$(git rev-parse origin/main)" + if [ "${TAG_EXISTS}" != "true" ]; then + git fetch origin main:refs/remotes/origin/main --depth=1 + MAIN_SHA="$(git rev-parse origin/main)" - echo "Creating tag ${TAG} at ${MAIN_SHA}" - git tag "${TAG}" "${MAIN_SHA}" - git push origin "${TAG}" + echo "Creating tag ${TAG} at ${MAIN_SHA}" + git tag "${TAG}" "${MAIN_SHA}" + git push origin "${TAG}" + else + echo "Using existing tag ${TAG}." + fi echo "Creating GitHub Release ${TAG}" gh release create "${TAG}" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3658441d3..759b1af0a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,17 +1,20 @@ name: Release CI on: + workflow_dispatch: push: branches: - - 'release/[0-9]+\.[0-9]+\.[0-9]+' - - 'hotfix/[0-9]+\.[0-9]+\.[0-9]+' - - 'pre-release/[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+' - - 'pre-release/[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+' - - 'pre-release/[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+' - - 'pre-release/[0-9]+\.[0-9]+\.[0-9]+-hotfix\.[0-9]+' + - 'release/**' + - 'hotfix/**' + - 'pre-release/**' jobs: release: + if: >- + github.event_name == 'workflow_dispatch' || + startsWith(github.ref_name, 'release/') || + startsWith(github.ref_name, 'hotfix/') || + startsWith(github.ref_name, 'pre-release/') runs-on: macos-latest permissions: id-token: write # OIDC for npm publish (single entry) @@ -37,6 +40,35 @@ jobs: git config user.name ${{ github.actor }} git config user.email ${{ github.actor }}@users.noreply.github.com + - name: Validate release branch name + run: | + set -euo pipefail + case "${GITHUB_REF_NAME}" in + release/*) + [[ "${GITHUB_REF_NAME}" =~ ^release/[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Invalid release branch: ${GITHUB_REF_NAME}" + exit 1 + } + ;; + hotfix/*) + [[ "${GITHUB_REF_NAME}" =~ ^hotfix/[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Invalid hotfix branch: ${GITHUB_REF_NAME}" + exit 1 + } + ;; + pre-release/*) + [[ "${GITHUB_REF_NAME}" =~ ^pre-release/[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|hotfix)\.[0-9]+$ ]] || { + echo "Invalid pre-release branch: ${GITHUB_REF_NAME}" + exit 1 + } + ;; + *) + echo "Unsupported release branch: ${GITHUB_REF_NAME}" + echo "For workflow_dispatch recovery, choose a release/*, hotfix/*, or pre-release/* branch in the Run workflow branch selector." + exit 1 + ;; + esac + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -328,8 +360,53 @@ jobs: NODE_OPTIONS: '--max_old_space_size=4096' run: node common/scripts/install-run-rush.js build --only @visactor/wx-vchart - - name: Publish to npm (release) + - name: Check npm version (release) if: startsWith(github.ref_name, 'release/') + id: npm_version_release + env: + PACKAGE_VERSION: ${{ steps.semver_release.outputs.main }} + run: | + set -euo pipefail + package_names=() + while IFS= read -r package_name; do + [ -n "$package_name" ] && package_names+=("$package_name") + done < <(node <<'NODE' + const fs = require('fs'); + const rush = JSON.parse(fs.readFileSync('rush.json', 'utf8')); + for (const project of rush.projects) { + if (project.shouldPublish) { + console.log(project.packageName); + } + } + NODE + ) + + existing=() + missing=() + for package_name in "${package_names[@]}"; do + if npm view "${package_name}@${PACKAGE_VERSION}" version --registry=https://registry.npmjs.org >/dev/null 2>&1; then + existing+=("${package_name}") + else + missing+=("${package_name}") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + echo "All packages already have version ${PACKAGE_VERSION} on npm, skip publish." + echo "skip_publish=true" >> "$GITHUB_OUTPUT" + elif [ "${#existing[@]}" -eq 0 ]; then + echo "No package has version ${PACKAGE_VERSION} on npm, continue publish." + echo "skip_publish=false" >> "$GITHUB_OUTPUT" + else + echo "Partial npm publish detected for ${PACKAGE_VERSION}." + echo "Existing packages: ${existing[*]}" + echo "Missing packages: ${missing[*]}" + echo "Stop before publish to avoid masking a partial release." + exit 1 + fi + + - name: Publish to npm (release) + if: startsWith(github.ref_name, 'release/') && steps.npm_version_release.outputs.skip_publish != 'true' run: node common/scripts/install-run-rush.js publish --publish --include-all --tag latest - name: Update shrinkwrap @@ -349,6 +426,9 @@ jobs: if git diff --quiet; then echo 'No changes to commit for release branch.' else + rm -rf .changelog + find docs/assets/changelog -name '*.bak' -delete + find packages/harmony_vchart/library -name '*.bak' -delete git add . git commit -m "build: release version ${{ steps.package_version_release.outputs.current_version }} [skip ci]" -n git push --no-verify origin ${{ github.ref_name }} @@ -396,8 +476,53 @@ jobs: if: startsWith(github.ref_name, 'hotfix/') run: node common/scripts/apply-release-version.js 'none' ${{ steps.semver_hotfix.outputs.main }} - - name: Publish to npm (hotfix) + - name: Check npm version (hotfix) if: startsWith(github.ref_name, 'hotfix/') + id: npm_version_hotfix + env: + PACKAGE_VERSION: ${{ steps.semver_hotfix.outputs.main }} + run: | + set -euo pipefail + package_names=() + while IFS= read -r package_name; do + [ -n "$package_name" ] && package_names+=("$package_name") + done < <(node <<'NODE' + const fs = require('fs'); + const rush = JSON.parse(fs.readFileSync('rush.json', 'utf8')); + for (const project of rush.projects) { + if (project.shouldPublish) { + console.log(project.packageName); + } + } + NODE + ) + + existing=() + missing=() + for package_name in "${package_names[@]}"; do + if npm view "${package_name}@${PACKAGE_VERSION}" version --registry=https://registry.npmjs.org >/dev/null 2>&1; then + existing+=("${package_name}") + else + missing+=("${package_name}") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + echo "All packages already have version ${PACKAGE_VERSION} on npm, skip publish." + echo "skip_publish=true" >> "$GITHUB_OUTPUT" + elif [ "${#existing[@]}" -eq 0 ]; then + echo "No package has version ${PACKAGE_VERSION} on npm, continue publish." + echo "skip_publish=false" >> "$GITHUB_OUTPUT" + else + echo "Partial npm publish detected for ${PACKAGE_VERSION}." + echo "Existing packages: ${existing[*]}" + echo "Missing packages: ${missing[*]}" + echo "Stop before publish to avoid masking a partial release." + exit 1 + fi + + - name: Publish to npm (hotfix) + if: startsWith(github.ref_name, 'hotfix/') && steps.npm_version_hotfix.outputs.skip_publish != 'true' run: node common/scripts/install-run-rush.js publish --publish --include-all --tag hotfix - name: Get npm version (hotfix) @@ -433,8 +558,53 @@ jobs: if: startsWith(github.ref_name, 'pre-release/') run: node common/scripts/apply-release-version.js ${{ steps.semver_prerelease.outputs.pre_release_name }} ${{ steps.semver_prerelease.outputs.main }} - - name: Publish to npm (pre-release) + - name: Check npm version (pre-release) if: startsWith(github.ref_name, 'pre-release/') + id: npm_version_prerelease + env: + PACKAGE_VERSION: ${{ steps.semver_prerelease.outputs.full }} + run: | + set -euo pipefail + package_names=() + while IFS= read -r package_name; do + [ -n "$package_name" ] && package_names+=("$package_name") + done < <(node <<'NODE' + const fs = require('fs'); + const rush = JSON.parse(fs.readFileSync('rush.json', 'utf8')); + for (const project of rush.projects) { + if (project.shouldPublish) { + console.log(project.packageName); + } + } + NODE + ) + + existing=() + missing=() + for package_name in "${package_names[@]}"; do + if npm view "${package_name}@${PACKAGE_VERSION}" version --registry=https://registry.npmjs.org >/dev/null 2>&1; then + existing+=("${package_name}") + else + missing+=("${package_name}") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + echo "All packages already have version ${PACKAGE_VERSION} on npm, skip publish." + echo "skip_publish=true" >> "$GITHUB_OUTPUT" + elif [ "${#existing[@]}" -eq 0 ]; then + echo "No package has version ${PACKAGE_VERSION} on npm, continue publish." + echo "skip_publish=false" >> "$GITHUB_OUTPUT" + else + echo "Partial npm publish detected for ${PACKAGE_VERSION}." + echo "Existing packages: ${existing[*]}" + echo "Missing packages: ${missing[*]}" + echo "Stop before publish to avoid masking a partial release." + exit 1 + fi + + - name: Publish to npm (pre-release) + if: startsWith(github.ref_name, 'pre-release/') && steps.npm_version_prerelease.outputs.skip_publish != 'true' run: node common/scripts/install-run-rush.js publish --publish --include-all --tag ${{ steps.semver_prerelease.outputs.pre_release_type }} - name: Get npm version (pre-release) diff --git a/.github/workflows/sync-main-to-develop.yml b/.github/workflows/sync-main-to-develop.yml index 940f4ec334..9cbbc57d94 100644 --- a/.github/workflows/sync-main-to-develop.yml +++ b/.github/workflows/sync-main-to-develop.yml @@ -1,6 +1,12 @@ name: Sync main to develop after release on: + workflow_dispatch: + inputs: + version: + description: 'Release version to sync, e.g. 1.2.3. Defaults to packages/vchart version on main.' + required: false + type: string pull_request: types: - closed @@ -9,7 +15,9 @@ on: jobs: sync_main_to_develop: - if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/') + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/')) runs-on: macos-latest @@ -24,12 +32,23 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + persist-credentials: false - name: Configure Git identity run: | git config user.name ${{ github.actor }} git config user.email ${{ github.actor }}@users.noreply.github.com + - name: Configure git remote for workflow-created branches + env: + GH_TOKEN: ${{ secrets.CREATE_TAG_RELEASE_TOKEN || github.token }} + run: | + set -euo pipefail + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -75,11 +94,15 @@ jobs: - name: Compute sync branch name and check existence id: sync_branch + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + PACKAGE_VERSION: ${{ steps.package_version.outputs.current_version }} run: | set -euo pipefail - VERSION="${{ steps.package_version.outputs.current_version }}" + VERSION="${INPUT_VERSION:-${PACKAGE_VERSION}}" BRANCH="sync/main-${VERSION}" echo "sync_branch=${BRANCH}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" git fetch origin "${BRANCH}":refs/remotes/origin/tmp-sync-branch 2>/dev/null || true if git ls-remote --exit-code --heads origin "${BRANCH}" > /dev/null 2>&1; then @@ -93,7 +116,6 @@ jobs: if: steps.sync_branch.outputs.exists == 'false' run: | set -euo pipefail - VERSION="${{ steps.package_version.outputs.current_version }}" BRANCH="${{ steps.sync_branch.outputs.sync_branch }}" git checkout main git pull --ff-only origin main @@ -101,9 +123,8 @@ jobs: git push --no-verify origin "${BRANCH}" - name: Create Pull Request to develop - if: steps.sync_branch.outputs.exists == 'false' env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.CREATE_TAG_RELEASE_TOKEN || github.token }} run: | set -euo pipefail BRANCH="${{ steps.sync_branch.outputs.sync_branch }}" @@ -115,7 +136,7 @@ jobs: exit 0 fi - TITLE="[Auto Sync] Sync the code from branch main to branch develop after release ${{ steps.package_version.outputs.current_version }}" - BODY="Sync the code from branch main to branch develop after release ${{ steps.package_version.outputs.current_version }}" + TITLE="[Auto Sync] Sync the code from branch main to branch develop after release ${{ steps.sync_branch.outputs.version }}" + BODY="Sync the code from branch main to branch develop after release ${{ steps.sync_branch.outputs.version }}" gh pr create --base develop --head "$BRANCH" --title "$TITLE" --body "$BODY" --reviewer xuefei1313 diff --git a/common/scripts/verify-release-workflows.js b/common/scripts/verify-release-workflows.js new file mode 100644 index 0000000000..73f6d09d4e --- /dev/null +++ b/common/scripts/verify-release-workflows.js @@ -0,0 +1,148 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + +const root = path.resolve(__dirname, '..', '..'); +const workflowsDir = path.join(root, '.github', 'workflows'); + +function loadWorkflow(fileName) { + const filePath = path.join(workflowsDir, fileName); + const content = fs.readFileSync(filePath, 'utf8'); + return { + fileName, + content, + data: yaml.load(content) + }; +} + +function getOn(workflow) { + return workflow.data.on || workflow.data['on']; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function hasOwn(object, key) { + return Object.prototype.hasOwnProperty.call(object || {}, key); +} + +function assertArrayIncludes(array, expected, context) { + assert(Array.isArray(array), `${context} must be an array`); + expected.forEach(item => { + assert(array.includes(item), `${context} must include ${item}`); + }); +} + +function getStep(workflow, jobName, stepName) { + const steps = workflow.data.jobs[jobName].steps || []; + return steps.find(step => step.name === stepName); +} + +function getSteps(workflow, jobName) { + return workflow.data.jobs[jobName].steps || []; +} + +function assertUsesPublishablePackageSet(step, context) { + assert(step && typeof step.run === 'string', `${context} step must exist`); + assert(step.run.includes("JSON.parse(fs.readFileSync('rush.json', 'utf8'))"), `${context} must read rush.json`); + assert(step.run.includes('project.shouldPublish'), `${context} must check every publishable Rush project`); + assert(step.run.includes('Partial npm publish detected'), `${context} must fail on partial npm publication`); + assert(step.run.includes('${missing[*]}'), `${context} must report missing packages`); + assert(step.run.includes('${existing[*]}'), `${context} must report existing packages`); +} + +function assertFallbackGhToken(step, context) { + assert(step && step.env && step.env.GH_TOKEN === '${{ secrets.CREATE_TAG_RELEASE_TOKEN || github.token }}', `${context} must use PAT fallback GH_TOKEN`); +} + +function run() { + const release = loadWorkflow('release.yml'); + const postRelease = loadWorkflow('post-release.yml'); + const syncMain = loadWorkflow('sync-main-to-develop.yml'); + const developDispatch = loadWorkflow('develop-synced-dispatch.yml'); + + const releaseOn = getOn(release); + assert(hasOwn(releaseOn, 'workflow_dispatch'), 'release.yml must support workflow_dispatch for manual recovery'); + assert( + release.data.jobs.release.if && + release.data.jobs.release.if.includes("github.event_name == 'workflow_dispatch'") && + release.data.jobs.release.if.includes("startsWith(github.ref_name, 'release/')") && + release.data.jobs.release.if.includes("startsWith(github.ref_name, 'hotfix/')") && + release.data.jobs.release.if.includes("startsWith(github.ref_name, 'pre-release/')"), + 'release.yml release job must allow manual recovery and guard automatic runs to release/hotfix/pre-release branches' + ); + assertArrayIncludes( + releaseOn.push.branches, + ['release/**', 'hotfix/**', 'pre-release/**'], + 'release.yml push branches' + ); + const validateBranchStep = getStep(release, 'release', 'Validate release branch name'); + assert(validateBranchStep && typeof validateBranchStep.run === 'string', 'release.yml must validate release branch names in the job'); + assert( + validateBranchStep.run.includes('^release/[0-9]+\\.[0-9]+\\.[0-9]+$') && + validateBranchStep.run.includes('^hotfix/[0-9]+\\.[0-9]+\\.[0-9]+$') && + validateBranchStep.run.includes('^pre-release/[0-9]+\\.[0-9]+\\.[0-9]+-(alpha|beta|rc|hotfix)\\.[0-9]+$') && + validateBranchStep.run.includes('For workflow_dispatch recovery'), + 'release.yml branch validation must enforce documented semver branch names and explain manual recovery refs' + ); + assert(getStep(release, 'release', 'Check npm version (release)'), 'release.yml must check npm before stable publish'); + assertUsesPublishablePackageSet(getStep(release, 'release', 'Check npm version (release)'), 'release npm check'); + assertUsesPublishablePackageSet(getStep(release, 'release', 'Check npm version (hotfix)'), 'hotfix npm check'); + assertUsesPublishablePackageSet(getStep(release, 'release', 'Check npm version (pre-release)'), 'pre-release npm check'); + assert( + release.content.includes("steps.npm_version_release.outputs.skip_publish != 'true'"), + 'release.yml stable publish must skip when npm already has the version' + ); + assert( + release.content.includes('rm -rf .changelog') && release.content.includes("find docs/assets/changelog -name '*.bak' -delete"), + 'release.yml must delete temporary changelog files before committing' + ); + + const postReleaseOn = getOn(postRelease); + assertArrayIncludes(postReleaseOn.repository_dispatch.types, ['develop-synced'], 'post-release.yml repository_dispatch types'); + assert(hasOwn(postReleaseOn, 'workflow_dispatch'), 'post-release.yml must keep workflow_dispatch recovery'); + assert( + postRelease.content.includes('PAYLOAD_VERSION') && postRelease.content.includes('INPUT_VERSION'), + 'post-release.yml must read version from repository_dispatch payload or workflow_dispatch input' + ); + getSteps(postRelease, 'post_release') + .filter(step => step.env && Object.prototype.hasOwnProperty.call(step.env, 'GH_TOKEN')) + .forEach(step => assertFallbackGhToken(step, `post-release ${step.name}`)); + assert(!postRelease.content.includes('GH_PAT'), 'post-release.yml must not keep PAT-only GH_PAT paths'); + assert( + postRelease.content.includes('tag_exists=${TAG_EXISTS}') && + postRelease.content.includes('release_exists=${RELEASE_EXISTS}') && + postRelease.content.includes('Create missing tag and GitHub Release'), + 'post-release.yml must allow release creation when the tag already exists' + ); + + const syncOn = getOn(syncMain); + assert(hasOwn(syncOn, 'workflow_dispatch'), 'sync-main-to-develop.yml must support workflow_dispatch recovery'); + assert( + syncMain.data.jobs.sync_main_to_develop.if && + syncMain.data.jobs.sync_main_to_develop.if.includes("github.event_name == 'workflow_dispatch'"), + 'sync-main-to-develop.yml job must allow workflow_dispatch' + ); + const syncCheckout = getStep(syncMain, 'sync_main_to_develop', 'Checkout'); + assert(syncCheckout && syncCheckout.with && syncCheckout.with.ref === 'main', 'sync-main-to-develop.yml must checkout main explicitly'); + assertFallbackGhToken(getStep(syncMain, 'sync_main_to_develop', 'Configure git remote for workflow-created branches'), 'sync-main remote setup'); + const syncPrStep = getStep(syncMain, 'sync_main_to_develop', 'Create Pull Request to develop'); + assertFallbackGhToken(syncPrStep, 'sync-main PR creation'); + assert(!syncPrStep.if, 'sync-main PR creation must run even when the sync branch already exists'); + + const dispatchOn = getOn(developDispatch); + assert(hasOwn(dispatchOn, 'workflow_dispatch'), 'develop-synced-dispatch.yml must support workflow_dispatch recovery'); + assert( + developDispatch.content.includes('PAYLOAD_VERSION') || developDispatch.content.includes('INPUT_VERSION'), + 'develop-synced-dispatch.yml must support an explicit dispatch version' + ); + assert(developDispatch.content.includes('--fail-with-body'), 'develop-synced-dispatch.yml dispatch curl must fail on HTTP errors'); + assertFallbackGhToken(getStep(developDispatch, 'dispatch_develop_synced', 'Send repository_dispatch develop-synced'), 'develop-synced dispatch'); + + console.log('Release workflow checks passed.'); +} + +run();