diff --git a/.github/actions/release-summary/action.yml b/.github/actions/release-summary/action.yml new file mode 100644 index 0000000000..b7e395558c --- /dev/null +++ b/.github/actions/release-summary/action.yml @@ -0,0 +1,34 @@ +name: Release summary +description: Writes what approving this run's gated job would ship to the job summary. +inputs: + gated-job: + description: Name of the environment-gated job as it appears on the run page. + required: true + version: + description: Version or image tag the gated job publishes. + required: true + paths: + description: Space-separated pathspecs that limit the listed changes to one package. + required: false + default: "" + published-step: + description: Step of the gated job that only succeeds when something was published. Earlier runs where it was skipped do not count as releases. + required: false + default: "" + publishes: + description: Set to false when the gated job will find nothing new to publish. + required: false + default: "" +runs: + using: composite + steps: + - name: Write the release summary + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GATED_JOB: ${{ inputs.gated-job }} + VERSION: ${{ inputs.version }} + PATHS: ${{ inputs.paths }} + PUBLISHED_STEP: ${{ inputs.published-step }} + PUBLISHES: ${{ inputs.publishes }} + run: bash "$GITHUB_ACTION_PATH/summary.sh" diff --git a/.github/actions/release-summary/summary.sh b/.github/actions/release-summary/summary.sh new file mode 100644 index 0000000000..b6c27a6f1b --- /dev/null +++ b/.github/actions/release-summary/summary.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Needs a checkout with full history and a token with contents, actions and +# pull-requests read. Never fails the job: a release must not wait on this. +set -uo pipefail + +repo="$GITHUB_REPOSITORY" +sha="$GITHUB_SHA" +web="$GITHUB_SERVER_URL/$repo" +workflow_file="${GITHUB_WORKFLOW_REF%@*}" +workflow_file="${workflow_file##*/}" +out="${GITHUB_STEP_SUMMARY:-/dev/stdout}" +max_commits=100 +PATHS="${PATHS:-}" +PUBLISHED_STEP="${PUBLISHED_STEP:-}" +PUBLISHES="${PUBLISHES:-}" + +cell() { printf '%s' "$1" | tr '\n' ' ' | sed 's/|/\\|/g'; } + +prev="" +prev_run="" +while read -r id run_sha; do + [ -n "$id" ] || continue + git merge-base --is-ancestor "$run_sha" "$sha" 2>/dev/null || continue + approved=$(gh api "repos/$repo/actions/runs/$id/jobs?filter=latest&per_page=100" | + jq --arg n "$GATED_JOB" --arg s "$PUBLISHED_STEP" '[.jobs[] + | select((.name == $n or (.name | startswith($n + " ("))) and .conclusion == "success") + | select($s == "" or any(.steps[]?; .name == $s and .conclusion == "success"))] | length') + if [ "${approved:-0}" -gt 0 ]; then + prev="$run_sha" + prev_run="$id" + break + fi +done < <(gh api "repos/$repo/actions/workflows/$workflow_file/runs?branch=$GITHUB_REF_NAME&status=completed&per_page=100" | + jq -r --argjson n "$GITHUB_RUN_NUMBER" '.workflow_runs[] | select(.run_number < $n) | "\(.id) \(.head_sha)"') + +subject=$(git log -1 --format=%s "$sha" 2>/dev/null) + +{ + echo "## Release summary" + echo + if [ "$PUBLISHES" = false ]; then + echo "Nothing new to publish: \`$(cell "$VERSION")\` is already released. Approving **$GATED_JOB** runs it without publishing." + else + echo "Approving **$GATED_JOB** publishes the version below from this commit." + fi + echo + echo "| | |" + echo "|---|---|" + echo "| Version | \`$(cell "$VERSION")\` |" + echo "| Commit | [\`${sha:0:10}\`]($web/commit/$sha) $(cell "$subject") |" + if [ -n "$prev" ]; then + tags="" + for t in $(git tag --points-at "$prev"); do + in_scope=${PATHS:+no} + for p in $PATHS; do + case "$t" in "${p%/}"/*) in_scope="" ;; esac + done + [ -z "$in_scope" ] && tags="${tags:+$tags, }$t" + done + echo "| Previously released | [\`${prev:0:10}\`]($web/commit/$prev)${tags:+ (tag $tags)}, released in [run $prev_run]($web/actions/runs/$prev_run) |" + echo "| Compare | [${prev:0:10}...${sha:0:10}]($web/compare/$prev...$sha) |" + else + echo "| Previously released | none found in the last 100 completed runs of $workflow_file on $GITHUB_REF_NAME |" + fi + echo +} >> "$out" + +[ -n "$prev" ] || exit 0 + +# shellcheck disable=SC2086 +mapfile -t commits < <(git log --format=%H "$prev..$sha" -- $PATHS) +scope="" +[ -n "$PATHS" ] && scope=" touching \`$PATHS\`" + +declare -A seen=() +pr_rows=() +direct=() +for c in "${commits[@]:0:$max_commits}"; do + prs=$(gh api "repos/$repo/commits/$c/pulls" | + jq -r '.[] | select(.merged_at != null) | [.number, .user.login, .title] | @tsv') + if [ -z "$prs" ]; then + direct+=("- [\`${c:0:10}\`]($web/commit/$c) $(git log -1 --format='%s (%an)' "$c")") + continue + fi + while IFS=$'\t' read -r number author title; do + [ -n "${seen[$number]:-}" ] && continue + seen[$number]=1 + approvers=$(gh api "repos/$repo/pulls/$number/reviews?per_page=100" | + jq -r '[.[] | select(.state == "APPROVED") | .user.login] | unique | join(", ")') + pr_rows+=("| [#$number]($web/pull/$number) | $(cell "$title") | $author | ${approvers:-**none**} |") + done <<< "$prs" +done + +{ + echo "### Merged pull requests$scope (${#pr_rows[@]})" + echo + if [ "${#pr_rows[@]}" -gt 0 ]; then + echo "| PR | Title | Author | Approved by |" + echo "|---|---|---|---|" + printf '%s\n' "${pr_rows[@]}" + else + echo "None." + fi + echo + if [ "${#direct[@]}" -gt 0 ]; then + echo "### Commits without a pull request (${#direct[@]})" + echo + printf '%s\n' "${direct[@]}" + echo + fi + if [ "${#commits[@]}" -gt "$max_commits" ]; then + echo "Only the newest $max_commits of ${#commits[@]} commits are listed. The compare link has the rest." + fi +} >> "$out" +exit 0 diff --git a/.github/actions/supersede-waiting-runs/action.yml b/.github/actions/supersede-waiting-runs/action.yml new file mode 100644 index 0000000000..978daecc09 --- /dev/null +++ b/.github/actions/supersede-waiting-runs/action.yml @@ -0,0 +1,10 @@ +name: Supersede unapproved release runs +description: Cancels earlier runs of this workflow on the same branch that are still waiting for environment approval and whose commit this run includes. +runs: + using: composite + steps: + - name: Cancel superseded runs waiting for approval + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: bash "$GITHUB_ACTION_PATH/supersede.sh" diff --git a/.github/actions/supersede-waiting-runs/supersede.sh b/.github/actions/supersede-waiting-runs/supersede.sh new file mode 100644 index 0000000000..3493e2b912 --- /dev/null +++ b/.github/actions/supersede-waiting-runs/supersede.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Needs actions write and contents read. Only a run whose status is "waiting" +# (held at an environment approval) is cancelled; a job that has started is never touched. +set -uo pipefail + +repo="$GITHUB_REPOSITORY" +web="$GITHUB_SERVER_URL/$repo" +workflow_file="${GITHUB_WORKFLOW_REF%@*}" +workflow_file="${workflow_file##*/}" +out="${GITHUB_STEP_SUMMARY:-/dev/stdout}" + +while read -r id sha; do + [ -n "$id" ] || continue + relation=$(gh api "repos/$repo/compare/$sha...$GITHUB_SHA" --jq .status) + case "$relation" in + ahead | identical) ;; + *) echo "Run $id on $sha is not included in $GITHUB_SHA ($relation); left waiting"; continue ;; + esac + state=$(gh api "repos/$repo/actions/runs/$id" --jq .status) + if [ "$state" != waiting ]; then + echo "Run $id is $state now; left alone" + continue + fi + if gh run cancel "$id" -R "$repo"; then + echo "- Cancelled [run $id]($web/actions/runs/$id), which was waiting for approval on \`${sha:0:10}\`. This run includes that commit." >> "$out" + fi +done < <(gh api "repos/$repo/actions/workflows/$workflow_file/runs?branch=$GITHUB_REF_NAME&status=waiting&per_page=100" | + jq -r --argjson n "$GITHUB_RUN_NUMBER" '.workflow_runs[] | select(.run_number < $n) | "\(.id) \(.head_sha)"') +exit 0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c102d15ae5..030b769705 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,6 +17,7 @@ jobs: permissions: contents: read outputs: + version: ${{ steps.check-version.outputs.version }} tag: ${{ steps.check-version.outputs.tag }} exists: ${{ steps.check-version.outputs.exists }} tarball: ${{ steps.pack.outputs.tarball }} @@ -46,6 +47,7 @@ jobs: run: | VERSION=$(node -p "require('./package.json').version") PACKAGE_NAME=$(node -p "require('./package.json').name") + echo "version=$VERSION" >> $GITHUB_OUTPUT if [[ "$VERSION" == *"-alexandria"* ]]; then echo "tag=alexandria" >> $GITHUB_OUTPUT @@ -83,11 +85,55 @@ jobs: if-no-files-found: error retention-days: 7 - publish: + supersede: + name: Supersede unapproved runs + needs: build + if: needs.build.outputs.exists == 'false' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + continue-on-error: true + with: + sparse-checkout: .github/actions + persist-credentials: false + - uses: ./.github/actions/supersede-waiting-runs + continue-on-error: true + timeout-minutes: 2 + + release-summary: + name: Release summary needs: build if: needs.build.outputs.exists == 'false' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + permissions: + contents: read + actions: read + pull-requests: read + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + continue-on-error: true + with: + fetch-depth: 0 + filter: blob:none + persist-credentials: false + - uses: ./.github/actions/release-summary + continue-on-error: true + timeout-minutes: 3 + with: + gated-job: publish + version: firecrawl-cli@${{ needs.build.outputs.version }} (npm tag ${{ needs.build.outputs.tag }}) + + publish: + needs: [build, release-summary] + if: needs.build.outputs.exists == 'false' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest environment: release + concurrency: + group: ${{ github.workflow }}-release-${{ github.ref }} + cancel-in-progress: false permissions: id-token: write steps: diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index a34d4aad5c..bccbd12264 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -200,12 +200,56 @@ jobs: if-no-files-found: error retention-days: 7 + supersede: + name: Supersede unapproved runs + needs: check-version + if: needs.check-version.outputs.released == 'false' && needs.check-version.outputs.dry_run != 'true' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + continue-on-error: true + with: + sparse-checkout: .github/actions + persist-credentials: false + - uses: ./.github/actions/supersede-waiting-runs + continue-on-error: true + timeout-minutes: 2 + + release-summary: + name: Release summary + needs: check-version + if: needs.check-version.outputs.released == 'false' && needs.check-version.outputs.dry_run != 'true' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + pull-requests: read + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + continue-on-error: true + with: + fetch-depth: 0 + filter: blob:none + persist-credentials: false + - uses: ./.github/actions/release-summary + continue-on-error: true + timeout-minutes: 3 + with: + gated-job: Create release and upload assets + version: v${{ needs.check-version.outputs.version }} + release: - needs: [check-version, package] + needs: [check-version, package, release-summary] if: needs.check-version.outputs.dry_run != 'true' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest name: Create release and upload assets environment: release + concurrency: + group: ${{ github.workflow }}-release-${{ github.ref }} + cancel-in-progress: false permissions: contents: write id-token: write