diff --git a/.github/workflows/release-on-resolved-issue.yml b/.github/workflows/release-on-resolved-issue.yml new file mode 100644 index 0000000..efaeee7 --- /dev/null +++ b/.github/workflows/release-on-resolved-issue.yml @@ -0,0 +1,98 @@ +name: Release a tested, resolved product issue + +on: + workflow_run: + workflows: ["Tests"] + types: [completed] + +permissions: + actions: write + contents: write + issues: read + pull-requests: read + +concurrency: + group: cli-release + cancel-in-progress: false + +jobs: + tag: + name: Tag a reviewed, integrated product release + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + persist-credentials: false + - id: release + name: Validate the tested resolving PR and calculate a tag + env: + GH_TOKEN: ${{ github.token }} + TESTED_SHA: ${{ github.event.workflow_run.head_sha }} + shell: bash + run: | + set -euo pipefail + query='query($owner:String!, $repo:String!) { repository(owner:$owner, name:$repo) { pullRequests(first:100, states:MERGED, baseRefName:"main", orderBy:{field:UPDATED_AT,direction:DESC}) { nodes { number mergeCommit { oid } labels(first:20) { nodes { name } } closingIssuesReferences(first:20) { nodes { number repository { nameWithOwner } labels(first:30) { nodes { name } } } } } } } }' + result="$(gh api graphql -f query="$query" -f owner="${GITHUB_REPOSITORY_OWNER}" -f repo="${GITHUB_REPOSITORY#*/}")" + matching="$(jq --arg sha "$TESTED_SHA" '[.data.repository.pullRequests.nodes[] | select(.mergeCommit.oid == $sha)]' <<< "$result")" + count="$(jq 'length' <<< "$matching")" + [ "$count" -eq 1 ] || { echo "Expected exactly one merged PR for tested SHA $TESTED_SHA; found $count." >&2; exit 1; } + + labels="$(jq -r '.[0].labels.nodes[].name' <<< "$matching")" + has_label() { grep -Fxq "$1" <<< "$labels"; } + has_label 'release:publish' || { echo 'Resolving PR must carry release:publish.' >&2; exit 1; } + mapfile -t bump_labels < <(grep -E '^release:(patch|minor|major)$' <<< "$labels" || true) + [ "${#bump_labels[@]}" -eq 1 ] || { echo 'Resolving PR must carry exactly one release:patch, release:minor, or release:major label.' >&2; exit 1; } + bump="${bump_labels[0]#release:}" + + issues="$(jq --arg repo "$GITHUB_REPOSITORY" '[.[0].closingIssuesReferences.nodes[] | select(.repository.nameWithOwner == $repo)]' <<< "$matching")" + issue_count="$(jq 'length' <<< "$issues")" + [ "$issue_count" -eq 1 ] || { echo "Expected exactly one same-repository issue closed by the PR; found $issue_count." >&2; exit 1; } + issue_labels="$(jq -r '.[0].labels.nodes[].name' <<< "$issues")" + issue_has_label() { grep -Fxq "$1" <<< "$issue_labels"; } + issue_has_label 'release:ready' || { echo 'Closed issue must carry release:ready before merge.' >&2; exit 1; } + issue_has_label 'direction:aligned' || { echo 'Closed issue must carry direction:aligned.' >&2; exit 1; } + issue_has_label 'roadmap' || { echo 'Closed issue must carry roadmap.' >&2; exit 1; } + ! issue_has_label 'direction:revise' || { echo 'Issue is still direction:revise.' >&2; exit 1; } + ! issue_has_label 'direction:discuss-close' || { echo 'Issue is direction:discuss-close.' >&2; exit 1; } + + latest="$(git tag -l 'cli-v*' --sort=-v:refname | head -n1 || true)" + if [[ "$latest" =~ ^cli-v([0-9]+)\.([0-9]+)\.([0-9]+)-[0-9a-f]{12}$ ]]; then + base="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" + else + legacy="$(git tag -l 'v*' --sort=-v:refname | head -n1 || true)" + [[ "$legacy" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || { echo 'No valid immutable or legacy CLI release tag was found.' >&2; exit 1; } + base="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" + fi + IFS='.' read -r major minor patch <<< "$base" + case "$bump" in + major) version="$((major + 1)).0.0" ;; + minor) version="$major.$((minor + 1)).0" ;; + patch) version="$major.$minor.$((patch + 1))" ;; + esac + grep -Eq "version=[\"']${version}[\"']" setup.py || { echo "setup.py must declare ${version}." >&2; exit 1; } + grep -Eq "__version__[[:space:]]*=[[:space:]]*[\"']${version}[\"']" diffgraph/__init__.py || { echo "diffgraph/__init__.py must declare ${version}." >&2; exit 1; } + tag="cli-v${version}-${TESTED_SHA:0:12}" + git rev-parse -q --verify "refs/tags/${tag}" >/dev/null && { echo "Release tag already exists: ${tag}" >&2; exit 1; } + echo "tag=$tag" >> "$GITHUB_OUTPUT" + - name: Create immutable release tag at the tested commit + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + TESTED_SHA: ${{ github.event.workflow_run.head_sha }} + shell: bash + run: | + set -euo pipefail + git tag -a "$TAG" "$TESTED_SHA" -m "Release $TAG" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push origin "$TAG" + - name: Publish the immutable tag + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + run: gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref main -f release_tag="$TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2af3c92..32e0a3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,20 +3,60 @@ name: Release native binaries on: push: tags: - - "v*" + - "cli-v*" + workflow_dispatch: + inputs: + release_tag: + description: "Immutable CLI tag to build and publish" + required: true + type: string permissions: contents: read concurrency: - group: release-${{ github.ref }} + group: release-${{ inputs.release_tag || github.ref_name }} cancel-in-progress: false +env: + RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} + jobs: + validate: + name: Validate immutable release tag + runs-on: ubuntu-latest + outputs: + source_sha: ${{ steps.source.outputs.sha }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} + fetch-depth: 0 + persist-credentials: false + - id: source + shell: bash + run: | + set -euo pipefail + tag="${RELEASE_TAG}" + if [[ "$tag" =~ ^cli-v([0-9]+)\.([0-9]+)\.([0-9]+)-([0-9a-f]{12})$ ]]; then + version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" + encoded_sha="${BASH_REMATCH[4]}" + else + echo "Expected cli-v-<12-char-sha>; got $tag" >&2 + exit 1 + fi + grep -Eq "version=[\"']${version}[\"']" setup.py || { echo "setup.py must declare ${version}." >&2; exit 1; } + grep -Eq "__version__[[:space:]]*=[[:space:]]*[\"']${version}[\"']" diffgraph/__init__.py || { echo "diffgraph/__init__.py must declare ${version}." >&2; exit 1; } + sha="$(git rev-parse HEAD)" + if [[ "${sha:0:12}" != "$encoded_sha" ]]; then + echo "Tag $tag must encode the checked-out commit; got $sha" >&2 + exit 1 + fi + echo "sha=$sha" >> "$GITHUB_OUTPUT" + build: name: Build ${{ matrix.target }} - permissions: - contents: read + needs: validate strategy: fail-fast: false matrix: @@ -46,6 +86,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + ref: ${{ needs.validate.outputs.source_sha }} persist-credentials: false - uses: actions/setup-python@v5 with: @@ -78,15 +119,28 @@ jobs: publish: name: Verify assets and create release - needs: build + needs: [validate, build] runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v4 with: + ref: ${{ needs.validate.outputs.source_sha }} fetch-depth: 0 persist-credentials: false + - name: Verify immutable tag still resolves to the validated source + env: + SOURCE_SHA: ${{ needs.validate.outputs.source_sha }} + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + tag_sha="$(git rev-parse "${RELEASE_TAG}^{commit}")" + test "$tag_sha" = "$SOURCE_SHA" || { + echo "Tag ${RELEASE_TAG} resolves to $tag_sha, not validated source $SOURCE_SHA." >&2 + exit 1 + } - name: Download all native binaries uses: actions/download-artifact@v4 with: @@ -135,17 +189,20 @@ jobs: ]; fs.writeFileSync('release/cli-manifest.json', JSON.stringify({ schemaVersion: 1, - version: process.env.GITHUB_REF_NAME, + version: process.env.RELEASE_TAG, + sourceCommit: process.env.SOURCE_SHA, assets, }, null, 2) + '\n'); NODE + env: + SOURCE_SHA: ${{ needs.validate.outputs.source_sha }} - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} shell: bash run: | set -euo pipefail - gh release create "${GITHUB_REF_NAME}" release/* \ + gh release create "${RELEASE_TAG}" release/* \ --repo "${GITHUB_REPOSITORY}" \ - --title "${GITHUB_REF_NAME}" \ + --title "${RELEASE_TAG}" \ --generate-notes