From bdecfba7a3c725847841893c6c0dc98f3739a63d Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Sun, 6 Sep 2026 07:17:15 +0530 Subject: [PATCH 1/2] ci: automate immutable CLI releases from tested product resolutions Require a successful main test run, explicit release labels, and a PR that closes one release-ready roadmap issue before tagging.\n\nValidate the immutable tag and source version before publishing so the extension can consume a verifiable CLI release. --- .../workflows/release-on-resolved-issue.yml | 96 +++++++++++++++++++ .github/workflows/release.yml | 57 +++++++++-- 2 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release-on-resolved-issue.yml diff --git a/.github/workflows/release-on-resolved-issue.yml b/.github/workflows/release-on-resolved-issue.yml new file mode 100644 index 0000000..b3157ff --- /dev/null +++ b/.github/workflows/release-on-resolved-issue.yml @@ -0,0 +1,96 @@ +name: Release a tested, resolved product issue + +on: + workflow_run: + workflows: ["Tests"] + types: [completed] + +permissions: + actions: read + 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: + 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 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..67aa397 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,20 +3,56 @@ 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 + echo "Expected cli-v-<12-char-sha>; got $tag" >&2 + exit 1 + fi + sha="$(git rev-parse HEAD)" + encoded_sha="${tag##*-}" + 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 +82,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + ref: ${{ env.RELEASE_TAG }} persist-credentials: false - uses: actions/setup-python@v5 with: @@ -78,13 +115,14 @@ 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: ${{ env.RELEASE_TAG }} fetch-depth: 0 persist-credentials: false - name: Download all native binaries @@ -135,17 +173,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 From 74df1d30d66ae825594a1244340f1d3f5d906e4a Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Mon, 7 Sep 2026 13:15:28 +0530 Subject: [PATCH 2/2] fix: harden immutable release workflow --- .../workflows/release-on-resolved-issue.yml | 4 +++- .github/workflows/release.yml | 24 +++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-on-resolved-issue.yml b/.github/workflows/release-on-resolved-issue.yml index b3157ff..efaeee7 100644 --- a/.github/workflows/release-on-resolved-issue.yml +++ b/.github/workflows/release-on-resolved-issue.yml @@ -6,7 +6,7 @@ on: types: [completed] permissions: - actions: read + actions: write contents: write issues: read pull-requests: read @@ -82,12 +82,14 @@ jobs: 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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67aa397..32e0a3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,12 +38,16 @@ jobs: run: | set -euo pipefail tag="${RELEASE_TAG}" - if [[ ! "$tag" =~ ^cli-v[0-9]+\.[0-9]+\.[0-9]+-[0-9a-f]{12}$ ]]; then + 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)" - encoded_sha="${tag##*-}" if [[ "${sha:0:12}" != "$encoded_sha" ]]; then echo "Tag $tag must encode the checked-out commit; got $sha" >&2 exit 1 @@ -82,7 +86,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ env.RELEASE_TAG }} + ref: ${{ needs.validate.outputs.source_sha }} persist-credentials: false - uses: actions/setup-python@v5 with: @@ -122,9 +126,21 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ env.RELEASE_TAG }} + 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: