fix(FR-2857): make pnpm install reliable under the minimumReleaseAge policy #1242
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
| name: "Sync PR Project Status" | |
| # Mirrors a PR's review lifecycle into the GitHub Project V2 "Status" column of | |
| # its linked issues. This repo's convention is that issues — not PRs — are | |
| # attached to the project, so we walk PR -> linked issues -> each issue's | |
| # project item. | |
| # | |
| # Event matrix: | |
| # pull_request_target.ready_for_review -> "In review" | |
| # pull_request_target.review_requested -> "In review" (re-review included) | |
| # pull_request_target.converted_to_draft -> "In progress" | |
| # pull_request_review.submitted (state=changes_requested) -> "Requested changes" | |
| # pull_request_review.submitted (approved / commented) -> no-op | |
| # | |
| # Link detection combines: | |
| # 1. GitHub's canonical `closingIssuesReferences` (covers manual "Development" | |
| # links and closing keywords auto-detected by GitHub). | |
| # 2. A regex fallback over the PR body that matches `(close|fix|resolve) | |
| # [sd|es|ed]? #<num>`. GitHub only registers closing keywords for PRs | |
| # targeting the repo's default branch, so stacked PRs (common here via | |
| # Graphite) never populate (1). The fallback is load-bearing — without | |
| # it this workflow would be a near-total no-op for stacked PRs. | |
| # | |
| # Configuration (repository or organization variables): | |
| # vars.PROJECT_ORGANIZATION — organization login that owns the Project V2. | |
| # User-owned projects are not supported; the | |
| # metadata query only resolves `organization(login:)`. | |
| # vars.PROJECT_NUMBER — project number (integer). If unset, the | |
| # workflow is a no-op. | |
| # | |
| # Secret: | |
| # secrets.PROJECT_TOKEN — PAT or GitHub App token with `project` scope. | |
| # The default GITHUB_TOKEN cannot write to | |
| # Projects V2. If unset, the workflow is a no-op. | |
| # | |
| # Notes: | |
| # * Uses `pull_request_target` and `pull_request_review` so the workflow has | |
| # access to secrets for PRs opened from forks. No PR code is checked out | |
| # or executed — only metadata is used via the GraphQL API. | |
| # * Cross-repo references (e.g. `Resolves owner/repo#123`) are not resolved. | |
| # Only same-repo issue numbers are followed. | |
| # * If a linked issue is not attached to the configured project, that issue | |
| # is skipped with a notice. No project items are created. | |
| on: | |
| pull_request_target: | |
| types: | |
| - ready_for_review | |
| - converted_to_draft | |
| - review_requested | |
| pull_request_review: | |
| types: | |
| - submitted | |
| permissions: | |
| pull-requests: read | |
| jobs: | |
| sync-project-status: | |
| runs-on: ubuntu-latest | |
| env: | |
| ORGANIZATION: ${{ vars.PROJECT_ORGANIZATION }} | |
| PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }} | |
| STATUS_FIELD_NAME: "Status" | |
| IN_REVIEW_STATUS_NAME: "In review" | |
| IN_PROGRESS_STATUS_NAME: "In progress" | |
| CHANGES_REQUESTED_STATUS_NAME: "Requested changes" | |
| steps: | |
| - name: Resolve target status from event | |
| id: target | |
| env: | |
| EVENT_ACTION: ${{ github.event.action }} | |
| REVIEW_STATE: ${{ github.event.review.state }} | |
| run: | | |
| case "${GITHUB_EVENT_NAME}|${EVENT_ACTION}" in | |
| "pull_request_target|ready_for_review" | "pull_request_target|review_requested") | |
| target="${IN_REVIEW_STATUS_NAME}" | |
| ;; | |
| "pull_request_target|converted_to_draft") | |
| target="${IN_PROGRESS_STATUS_NAME}" | |
| ;; | |
| "pull_request_review|submitted") | |
| if [ "${REVIEW_STATE}" = "changes_requested" ]; then | |
| target="${CHANGES_REQUESTED_STATUS_NAME}" | |
| else | |
| echo "::notice::Review submitted with state='${REVIEW_STATE}'. No status change." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| ;; | |
| *) | |
| echo "::notice::Unsupported event '${GITHUB_EVENT_NAME}' action='${EVENT_ACTION}'. Skipping." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| ;; | |
| esac | |
| echo "status_name=${target}" >> "$GITHUB_OUTPUT" | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| - name: Fetch Project V2 metadata | |
| id: project | |
| if: steps.target.outputs.skip != 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.PROJECT_TOKEN }} | |
| TARGET_STATUS: ${{ steps.target.outputs.status_name }} | |
| run: | | |
| if [ -z "$ORGANIZATION" ] || [ -z "$PROJECT_NUMBER" ]; then | |
| echo "::warning::vars.PROJECT_ORGANIZATION or vars.PROJECT_NUMBER is not configured. Skipping." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [ -z "$GH_TOKEN" ]; then | |
| echo "::warning::secrets.PROJECT_TOKEN is not configured. Skipping." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| gh api graphql \ | |
| -f query=' | |
| query($org: String!, $number: Int!) { | |
| organization(login: $org) { | |
| projectV2(number: $number) { | |
| id | |
| fields(first: 50) { | |
| nodes { | |
| ... on ProjectV2SingleSelectField { | |
| id | |
| name | |
| options { id name } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }' \ | |
| -f org="$ORGANIZATION" \ | |
| -F number="$PROJECT_NUMBER" \ | |
| > project.json | |
| project_id=$(jq -r '.data.organization.projectV2.id // empty' project.json) | |
| if [ -z "$project_id" ]; then | |
| echo "::warning::Project V2 '${ORGANIZATION}/#${PROJECT_NUMBER}' not found or inaccessible. Check PROJECT_TOKEN permissions. Skipping." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| status_field_id=$(jq -r \ | |
| --arg name "$STATUS_FIELD_NAME" \ | |
| '.data.organization.projectV2.fields.nodes[]? | select(.name == $name) | .id // empty' \ | |
| project.json) | |
| option_id=$(jq -r \ | |
| --arg name "$STATUS_FIELD_NAME" \ | |
| --arg option "$TARGET_STATUS" \ | |
| '.data.organization.projectV2.fields.nodes[]? | select(.name == $name) | .options[]? | select(.name == $option) | .id // empty' \ | |
| project.json) | |
| if [ -z "$status_field_id" ] || [ -z "$option_id" ]; then | |
| echo "::warning::Status field '$STATUS_FIELD_NAME' or option '$TARGET_STATUS' not found in project. Skipping." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "project_id=$project_id" >> "$GITHUB_OUTPUT" | |
| echo "status_field_id=$status_field_id" >> "$GITHUB_OUTPUT" | |
| echo "option_id=$option_id" >> "$GITHUB_OUTPUT" | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| - name: Collect linked issue numbers | |
| id: issues | |
| if: steps.target.outputs.skip != 'true' && steps.project.outputs.skip != 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.PROJECT_TOKEN }} | |
| PR_URL: ${{ github.event.pull_request.html_url }} | |
| PR_BODY: ${{ github.event.pull_request.body }} | |
| run: | | |
| # Source 1: GitHub's canonical closingIssuesReferences. | |
| gh api graphql \ | |
| -f query=' | |
| query($url: URI!) { | |
| resource(url: $url) { | |
| ... on PullRequest { | |
| closingIssuesReferences(first: 20) { | |
| nodes { number } | |
| } | |
| } | |
| } | |
| }' \ | |
| -f url="$PR_URL" \ | |
| > closing.json | |
| api_numbers=$(jq -r '.data.resource.closingIssuesReferences.nodes[]?.number // empty' closing.json) | |
| # Source 2: regex fallback over PR body. Many PRs in this repo use | |
| # `Resolves #123 (FR-123)` without a GitHub-detected link; without | |
| # this fallback the workflow would be a near-total no-op. | |
| body_numbers=$( | |
| printf '%s' "$PR_BODY" \ | |
| | grep -oiE '(close[sd]?|fix(es|ed)?|resolve[sd]?)[[:space:]]+#[0-9]+' \ | |
| | grep -oE '#[0-9]+' \ | |
| | tr -d '#' \ | |
| || true | |
| ) | |
| numbers=$(printf '%s\n%s\n' "$api_numbers" "$body_numbers" \ | |
| | grep -E '^[0-9]+$' \ | |
| | sort -u \ | |
| || true) | |
| if [ -z "$numbers" ]; then | |
| echo "::notice::No linked issues detected for PR #${{ github.event.pull_request.number }}. Nothing to update." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "Detected linked issues:" | |
| echo "$numbers" | sed 's/^/ - #/' | |
| { | |
| echo "numbers<<__EOF__" | |
| echo "$numbers" | |
| echo "__EOF__" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| - name: Update project status for each linked issue | |
| if: steps.target.outputs.skip != 'true' && steps.project.outputs.skip != 'true' && steps.issues.outputs.skip != 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.PROJECT_TOKEN }} | |
| REPO_FULL_NAME: ${{ github.event.pull_request.base.repo.full_name }} | |
| PROJECT_ID: ${{ steps.project.outputs.project_id }} | |
| STATUS_FIELD_ID: ${{ steps.project.outputs.status_field_id }} | |
| OPTION_ID: ${{ steps.project.outputs.option_id }} | |
| TARGET_STATUS: ${{ steps.target.outputs.status_name }} | |
| NUMBERS: ${{ steps.issues.outputs.numbers }} | |
| run: | | |
| owner_name="${REPO_FULL_NAME%/*}" | |
| repo_name="${REPO_FULL_NAME#*/}" | |
| updated=0 | |
| skipped=0 | |
| while IFS= read -r issue_number; do | |
| [ -z "$issue_number" ] && continue | |
| gh api graphql \ | |
| -f query=' | |
| query($owner: String!, $name: String!, $number: Int!) { | |
| repository(owner: $owner, name: $name) { | |
| issue(number: $number) { | |
| projectItems(first: 20) { | |
| nodes { id project { id } } | |
| } | |
| } | |
| } | |
| }' \ | |
| -f owner="$owner_name" \ | |
| -f name="$repo_name" \ | |
| -F number="$issue_number" \ | |
| > "issue_${issue_number}.json" | |
| item_id=$(jq -r \ | |
| --arg pid "$PROJECT_ID" \ | |
| '[.data.repository.issue.projectItems.nodes[]? | select(.project.id == $pid) | .id] | first // empty' \ | |
| "issue_${issue_number}.json") | |
| if [ -z "$item_id" ]; then | |
| echo "::notice::Issue #${issue_number} is not attached to '${ORGANIZATION}/#${PROJECT_NUMBER}'. Skipping." | |
| skipped=$((skipped + 1)) | |
| continue | |
| fi | |
| gh api graphql \ | |
| -f query=' | |
| mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) { | |
| updateProjectV2ItemFieldValue(input: { | |
| projectId: $project | |
| itemId: $item | |
| fieldId: $field | |
| value: { singleSelectOptionId: $option } | |
| }) { | |
| projectV2Item { id } | |
| } | |
| }' \ | |
| -f project="$PROJECT_ID" \ | |
| -f item="$item_id" \ | |
| -f field="$STATUS_FIELD_ID" \ | |
| -f option="$OPTION_ID" \ | |
| > /dev/null | |
| echo "::notice::Issue #${issue_number} status set to \"${TARGET_STATUS}\"." | |
| updated=$((updated + 1)) | |
| done <<< "$NUMBERS" | |
| echo "::notice::PR #${{ github.event.pull_request.number }} sync summary: updated=${updated}, skipped=${skipped}." |