From 6b0adfb330667c5728628d6efa56838f319fa08b Mon Sep 17 00:00:00 2001 From: relayfile Date: Sun, 2 Aug 2026 08:07:03 -0400 Subject: [PATCH 1/3] fix(ci): stop release workflow pushing main --- .github/workflows/publish.yml | 58 ++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d3c84279..fab65728 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -101,6 +101,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: + fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node.js @@ -125,11 +126,31 @@ jobs: - name: Version all packages id: bump run: | + set -euo pipefail CUSTOM_VERSION="${{ github.event.inputs.custom_version }}" VERSION_TYPE="${{ github.event.inputs.version }}" PREID="${{ github.event.inputs.preid }}" - CURRENT_VERSION=$(node -p "require('./package.json').version") - echo "Current version: $CURRENT_VERSION" + MANIFEST_VERSION=$(node -p "require('./package.json').version") + LATEST_TAG=$(git for-each-ref \ + --sort=-version:refname \ + --sort=-creatordate \ + --format='%(refname:short)' \ + 'refs/tags/v[0-9]*' | sed -n '1p') + + if [ -z "$LATEST_TAG" ]; then + echo "ERROR: no release tag exists; cannot derive the release baseline" >&2 + exit 1 + fi + + CURRENT_VERSION="${LATEST_TAG#v}" + echo "Manifest version: $MANIFEST_VERSION" + echo "Release baseline: $CURRENT_VERSION ($LATEST_TAG)" + + # Release bumps are intentionally not committed back to the protected + # default branch. Seed the ephemeral build tree from the latest tag so + # later releases still advance after package.json stops being a release + # ledger on main. + npm version "$CURRENT_VERSION" --no-git-tag-version --allow-same-version if [ -n "$CUSTOM_VERSION" ]; then echo "Setting version to custom value: $CUSTOM_VERSION" @@ -140,6 +161,10 @@ jobs: fi NEW_VERSION=$(node -p "require('./package.json').version") + if git show-ref --verify --quiet "refs/tags/v${NEW_VERSION}"; then + echo "ERROR: release tag v${NEW_VERSION} already exists" >&2 + exit 1 + fi echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "New version: $NEW_VERSION" @@ -574,37 +599,14 @@ jobs: ( cd packages/cli/bin && sha256sum relayfile-cli-* ) >> checksums.txt cat checksums.txt - - name: Regenerate release lockfiles - run: | - set -euo pipefail - npm install --package-lock-only --ignore-scripts - npm install --prefix packages/sdk/typescript --package-lock-only --ignore-scripts - npm ci --dry-run - npm ci --prefix packages/sdk/typescript --dry-run - - - name: Commit version bump and create tag + - name: Create and push release tag env: NEW_VERSION: ${{ needs.build.outputs.new_version }} run: | + set -euo pipefail git config user.name "GitHub Actions" git config user.email "actions@github.com" - - git add \ - package.json package-lock.json \ - packages/core/package.json packages/core/CHANGELOG.md \ - packages/sdk/typescript/package.json packages/sdk/typescript/package-lock.json packages/sdk/typescript/CHANGELOG.md \ - packages/client/package.json packages/client/CHANGELOG.md \ - packages/agents/package.json packages/agents/CHANGELOG.md \ - packages/cli/package.json packages/cli/CHANGELOG.md \ - packages/file-observer/package.json packages/file-observer/CHANGELOG.md \ - packages/local-mount/package.json packages/local-mount/CHANGELOG.md \ - packages/mount-darwin-arm64/package.json packages/mount-darwin-x64/package.json \ - packages/mount-linux-arm64/package.json packages/mount-linux-x64/package.json - if ! git diff --staged --quiet; then - git commit -m "chore(release): v${NEW_VERSION}" - git push - fi - + echo "Tagging source commit $(git rev-parse HEAD) as v${NEW_VERSION}" git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}" git push origin "v${NEW_VERSION}" From 55494456cb4c060a194e8a403b2d2e5efcf31e12 Mon Sep 17 00:00:00 2001 From: relayfile Date: Sun, 2 Aug 2026 09:06:59 -0400 Subject: [PATCH 2/3] fix(ci): harden protected release path --- .github/workflows/contract.yml | 3 + .github/workflows/publish.yml | 111 ++++++++++++++++++++---------- scripts/check-publish-workflow.sh | 37 ++++++++++ 3 files changed, 114 insertions(+), 37 deletions(-) create mode 100755 scripts/check-publish-workflow.sh diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index c8e86f66..8e281685 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -40,3 +40,6 @@ jobs: - name: Validate contract surface run: ./scripts/check-contract-surface.sh + + - name: Validate protected release workflow + run: ./scripts/check-publish-workflow.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fab65728..05957d4a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -84,16 +84,18 @@ jobs: steps: - name: Validate release mode + env: + RELEASE_PACKAGE: ${{ github.event.inputs.package }} + DRY_RUN: ${{ github.event.inputs.dry_run }} run: | set -euo pipefail - if [ "${{ github.event.inputs.package }}" != "all" ] && [ "${{ github.event.inputs.dry_run }}" != "true" ]; then + if [ "$RELEASE_PACKAGE" != "all" ] && [ "$DRY_RUN" != "true" ]; then cat >&2 <<'EOF' ERROR: the publish workflow version-bumps every @relayfile package, and - release lockfile regeneration needs every @relayfile/mount-* package at - the new version to have been published so npm can resolve tarball - integrity metadata. Run the release with package=all, or split - single-package releases into a workflow that does not rewrite all - package manifests and lockfiles. + publishing only one package would leave the other package manifests at + an unpublished version inside the release artifact. Run the release + with package=all, or split single-package releases into a workflow that + does not rewrite every package manifest. EOF exit 1 fi @@ -125,15 +127,15 @@ jobs: - name: Version all packages id: bump + env: + CUSTOM_VERSION: ${{ github.event.inputs.custom_version }} + VERSION_TYPE: ${{ github.event.inputs.version }} + PREID: ${{ github.event.inputs.preid }} run: | set -euo pipefail - CUSTOM_VERSION="${{ github.event.inputs.custom_version }}" - VERSION_TYPE="${{ github.event.inputs.version }}" - PREID="${{ github.event.inputs.preid }}" MANIFEST_VERSION=$(node -p "require('./package.json').version") - LATEST_TAG=$(git for-each-ref \ + LATEST_TAG=$(git -c versionsort.suffix=- for-each-ref \ --sort=-version:refname \ - --sort=-creatordate \ --format='%(refname:short)' \ 'refs/tags/v[0-9]*' | sed -n '1p') @@ -352,16 +354,46 @@ jobs: path: relayfile-mount-${{ matrix.suffix }} retention-days: 1 + # Reserve the version on the remote before any irreversible npm publish. + # The explicit tag refspec is atomic for this ref: either a competing tag + # writer wins or this job wins, but packages are never published first and + # then left without their intended tag. + reserve-release-tag: + name: Reserve release tag + needs: [build, build-mount-binaries] + runs-on: ubuntu-latest + if: | + github.event.inputs.dry_run != 'true' && + needs.build.result == 'success' && + needs.build-mount-binaries.result == 'success' + steps: + - name: Checkout source commit + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Reserve version tag + env: + NEW_VERSION: ${{ needs.build.outputs.new_version }} + run: | + set -euo pipefail + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}" + git push origin "refs/tags/v${NEW_VERSION}:refs/tags/v${NEW_VERSION}" + # Publish all packages in parallel publish-packages: name: Publish ${{ matrix.package }} - needs: [build, build-mount-binaries] + needs: [build, build-mount-binaries, reserve-release-tag] runs-on: ubuntu-latest if: | always() && github.event.inputs.package == 'all' && needs.build.result == 'success' && - (github.event.inputs.dry_run == 'true' || needs.build-mount-binaries.result == 'success') + (github.event.inputs.dry_run == 'true' || + (needs.build-mount-binaries.result == 'success' && needs.reserve-release-tag.result == 'success')) strategy: fail-fast: false max-parallel: 10 @@ -444,26 +476,31 @@ jobs: - name: Dry run check if: github.event.inputs.dry_run == 'true' working-directory: ${{ matrix.path }} + env: + NPM_TAG: ${{ github.event.inputs.tag }} run: | PACKAGE_NAME=$(node -p "require('./package.json').name") echo "Dry run - would publish ${PACKAGE_NAME}" - npm publish --dry-run --access public --tag ${{ github.event.inputs.tag }} --ignore-scripts + npm publish --dry-run --access public --tag "$NPM_TAG" --ignore-scripts - name: Publish to NPM if: github.event.inputs.dry_run != 'true' working-directory: ${{ matrix.path }} - run: npm publish --access public --provenance --tag ${{ github.event.inputs.tag }} --ignore-scripts + env: + NPM_TAG: ${{ github.event.inputs.tag }} + run: npm publish --access public --provenance --tag "$NPM_TAG" --ignore-scripts # Publish single package (when not 'all') publish-single: name: Publish single package - needs: [build, build-mount-binaries] + needs: [build, build-mount-binaries, reserve-release-tag] runs-on: ubuntu-latest if: | always() && github.event.inputs.package != 'all' && needs.build.result == 'success' && - (github.event.inputs.dry_run == 'true' || needs.build-mount-binaries.result == 'success') + (github.event.inputs.dry_run == 'true' || + (needs.build-mount-binaries.result == 'success' && needs.reserve-release-tag.result == 'success')) steps: - name: Checkout code @@ -487,8 +524,10 @@ jobs: - name: Resolve package path id: resolve-package + env: + RELEASE_PACKAGE: ${{ github.event.inputs.package }} run: | - case "${{ github.event.inputs.package }}" in + case "$RELEASE_PACKAGE" in core) echo "path=packages/core" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; sdk) echo "path=packages/sdk/typescript" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; client) echo "path=packages/client" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; @@ -501,7 +540,7 @@ jobs: mount-linux-arm64) echo "path=packages/mount-linux-arm64" >> "$GITHUB_OUTPUT"; echo "mount_binary=relayfile-mount-linux-arm64" >> "$GITHUB_OUTPUT" ;; mount-linux-x64) echo "path=packages/mount-linux-x64" >> "$GITHUB_OUTPUT"; echo "mount_binary=relayfile-mount-linux-amd64" >> "$GITHUB_OUTPUT" ;; *) - echo "Unsupported package: ${{ github.event.inputs.package }}" >&2 + echo "Unsupported package: $RELEASE_PACKAGE" >&2 exit 1 ;; esac @@ -529,24 +568,29 @@ jobs: - name: Dry run check if: github.event.inputs.dry_run == 'true' working-directory: ${{ steps.resolve-package.outputs.path }} + env: + NPM_TAG: ${{ github.event.inputs.tag }} run: | PACKAGE_NAME=$(node -p "require('./package.json').name") echo "Dry run - would publish ${PACKAGE_NAME}" - npm publish --dry-run --access public --tag ${{ github.event.inputs.tag }} --ignore-scripts + npm publish --dry-run --access public --tag "$NPM_TAG" --ignore-scripts - name: Publish to NPM if: github.event.inputs.dry_run != 'true' working-directory: ${{ steps.resolve-package.outputs.path }} - run: npm publish --access public --provenance --tag ${{ github.event.inputs.tag }} --ignore-scripts + env: + NPM_TAG: ${{ github.event.inputs.tag }} + run: npm publish --access public --provenance --tag "$NPM_TAG" --ignore-scripts create-release: name: Create Release - needs: [build, publish-packages, publish-single, build-mount-binaries] + needs: [build, publish-packages, publish-single, build-mount-binaries, reserve-release-tag] runs-on: ubuntu-latest if: | always() && github.event.inputs.dry_run != 'true' && needs.build-mount-binaries.result == 'success' && + needs.reserve-release-tag.result == 'success' && (needs.publish-packages.result == 'success' || needs.publish-single.result == 'success') steps: @@ -599,17 +643,6 @@ jobs: ( cd packages/cli/bin && sha256sum relayfile-cli-* ) >> checksums.txt cat checksums.txt - - name: Create and push release tag - env: - NEW_VERSION: ${{ needs.build.outputs.new_version }} - run: | - set -euo pipefail - git config user.name "GitHub Actions" - git config user.email "actions@github.com" - echo "Tagging source commit $(git rev-parse HEAD) as v${NEW_VERSION}" - git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}" - git push origin "v${NEW_VERSION}" - - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -669,21 +702,25 @@ jobs: summary: name: Summary - needs: [build, publish-packages, publish-single, build-mount-binaries, create-release] + needs: [build, publish-packages, publish-single, build-mount-binaries, reserve-release-tag, create-release] runs-on: ubuntu-latest if: always() steps: - name: Summary + env: + RELEASE_PACKAGE: ${{ github.event.inputs.package }} + NPM_TAG: ${{ github.event.inputs.tag }} + DRY_RUN: ${{ github.event.inputs.dry_run }} run: | { echo "## Publish Summary" echo "" - echo "**Package**: \`${{ github.event.inputs.package }}\`" + echo "**Package**: \`${RELEASE_PACKAGE}\`" echo "**Version**: \`${{ needs.build.outputs.new_version }}\`" - echo "**NPM Tag**: \`${{ github.event.inputs.tag }}\`" + echo "**NPM Tag**: \`${NPM_TAG}\`" echo "**Prerelease**: \`${{ needs.build.outputs.is_prerelease }}\`" - echo "**Dry Run**: \`${{ github.event.inputs.dry_run }}\`" + echo "**Dry Run**: \`${DRY_RUN}\`" echo "**Provenance**: \`enabled\`" echo "" echo "### Results" diff --git a/scripts/check-publish-workflow.sh b/scripts/check-publish-workflow.sh new file mode 100755 index 00000000..3c8c4605 --- /dev/null +++ b/scripts/check-publish-workflow.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +workflow_file="${1:-.github/workflows/publish.yml}" +allowed_tag_push='git push origin "refs/tags/v${NEW_VERSION}:refs/tags/v${NEW_VERSION}"' +push_count=0 + +while IFS= read -r source_line; do + line="${source_line#"${source_line%%[![:space:]]*}"}" + + case "$line" in + \#*|'') + continue + ;; + esac + + if [[ "$line" == git\ add* || "$line" == git\ commit* ]]; then + echo "publish workflow check failed: release workflow may not create commits: $line" >&2 + exit 1 + fi + + if [[ "$line" == git\ push* ]]; then + if [[ "$line" != "$allowed_tag_push" ]]; then + echo "publish workflow check failed: only the explicit release-tag refspec may be pushed: $line" >&2 + exit 1 + fi + push_count=$((push_count + 1)) + fi +done < "$workflow_file" + +if [[ "$push_count" -ne 1 ]]; then + echo "publish workflow check failed: expected exactly one explicit release-tag push, found $push_count" >&2 + exit 1 +fi + +echo "publish workflow check passed" From 131bd34ce8b6ad1f0060b5039e981bfed00a52b9 Mon Sep 17 00:00:00 2001 From: relayfile Date: Sun, 2 Aug 2026 09:55:21 -0400 Subject: [PATCH 3/3] fix(ci): make release recovery idempotent --- .github/workflows/contract.yml | 8 +- .github/workflows/publish-python.yml | 135 +++++++++++++++++++------ .github/workflows/publish.yml | 94 +++++++++++++++-- scripts/check-publish-workflow.sh | 38 ++++--- scripts/test-check-publish-workflow.sh | 33 ++++++ 5 files changed, 255 insertions(+), 53 deletions(-) create mode 100755 scripts/test-check-publish-workflow.sh diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 8e281685..8db928ba 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -42,4 +42,10 @@ jobs: run: ./scripts/check-contract-surface.sh - name: Validate protected release workflow - run: ./scripts/check-publish-workflow.sh + run: | + for workflow in .github/workflows/*.yml; do + ./scripts/check-publish-workflow.sh "$workflow" allow-zero + done + ./scripts/check-publish-workflow.sh + ./scripts/check-publish-workflow.sh .github/workflows/publish-python.yml + ./scripts/test-check-publish-workflow.sh diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml index 206a5fec..8d8bb6a4 100644 --- a/.github/workflows/publish-python.yml +++ b/.github/workflows/publish-python.yml @@ -66,15 +66,43 @@ jobs: # interpolated into the shell script body. CUSTOM_VERSION: ${{ github.event.inputs.custom_version }} VERSION_TYPE: ${{ github.event.inputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set -euo pipefail PYPROJECT="pyproject.toml" - CURRENT_VERSION=$(awk -F '"' '/^version = / { print $2; exit }' "$PYPROJECT") - if [ -z "$CURRENT_VERSION" ]; then - echo "Unable to read current version from $PYPROJECT" + MANIFEST_VERSION=$(awk -F '"' '/^version = / { print $2; exit }' "$PYPROJECT") + LATEST_TAG="" + while IFS= read -r CANDIDATE_TAG; do + RELEASE_ERROR=$(mktemp) + if gh api \ + "repos/${GITHUB_REPOSITORY}/releases/tags/${CANDIDATE_TAG}" \ + --silent 2>"$RELEASE_ERROR"; then + LATEST_TAG="$CANDIDATE_TAG" + rm -f "$RELEASE_ERROR" + break + fi + if grep -q 'HTTP 404' "$RELEASE_ERROR"; then + rm -f "$RELEASE_ERROR" + continue + fi + cat "$RELEASE_ERROR" >&2 + rm -f "$RELEASE_ERROR" + echo "ERROR: could not verify completed release ${CANDIDATE_TAG}" >&2 + exit 1 + done < <(git -c versionsort.suffix=- for-each-ref \ + --sort=-version:refname \ + --format='%(refname:short)' \ + 'refs/tags/sdk-python-v[0-9]*') + + if [ -z "$LATEST_TAG" ]; then + echo "ERROR: no completed Python SDK GitHub release exists" >&2 exit 1 fi - echo "Current version: $CURRENT_VERSION" + + CURRENT_VERSION="${LATEST_TAG#sdk-python-v}" + echo "Manifest version: $MANIFEST_VERSION" + echo "Release baseline: $CURRENT_VERSION ($LATEST_TAG)" bump_semver() { local current="$1" @@ -136,6 +164,18 @@ jobs: echo "Bumped version: $VERSION_TYPE -> $NEW_VERSION" fi + if git show-ref --verify --quiet "refs/tags/sdk-python-v${NEW_VERSION}"; then + TAG_COMMIT=$(git rev-list -n 1 "refs/tags/sdk-python-v${NEW_VERSION}") + if [ "$TAG_COMMIT" != "$GITHUB_SHA" ]; then + echo "ERROR: sdk-python-v${NEW_VERSION} exists at ${TAG_COMMIT}, not ${GITHUB_SHA}" >&2 + exit 1 + fi + echo "is_recovery=true" >> "$GITHUB_OUTPUT" + echo "Recovering Python SDK release reserved by this source commit" + else + echo "is_recovery=false" >> "$GITHUB_OUTPUT" + fi + # Update pyproject.toml [project] version (first match only). awk -v v="$NEW_VERSION" ' !done && /^version = / { print "version = \"" v "\""; done=1; next } @@ -160,33 +200,62 @@ jobs: - name: Check distribution metadata run: uvx twine check dist/* - - name: Publish to PyPI - if: github.ref == 'refs/heads/main' && github.event.inputs.dry_run != 'true' - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: packages/sdk/python/dist + - name: Check PyPI version state + id: pypi-state + env: + NEW_VERSION: ${{ steps.bump.outputs.new_version }} + RELEASE_RECOVERY: ${{ steps.bump.outputs.is_recovery }} + run: | + set -euo pipefail + STATUS=$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + "https://pypi.org/pypi/relayfile-sdk/${NEW_VERSION}/json") + case "$STATUS" in + 200) + if [ "$RELEASE_RECOVERY" != "true" ]; then + echo "ERROR: relayfile-sdk ${NEW_VERSION} exists without a same-commit tag reservation" >&2 + exit 1 + fi + echo "published=true" >> "$GITHUB_OUTPUT" + ;; + 404) + echo "published=false" >> "$GITHUB_OUTPUT" + ;; + *) + echo "ERROR: PyPI version lookup returned HTTP ${STATUS}" >&2 + exit 1 + ;; + esac - - name: Commit and tag + - name: Reserve Python SDK version tag if: github.ref == 'refs/heads/main' && github.event.inputs.dry_run != 'true' working-directory: ${{ github.workspace }} + env: + NEW_VERSION: ${{ steps.bump.outputs.new_version }} run: | - NEW_VERSION="${{ steps.bump.outputs.new_version }}" - + set -euo pipefail git config user.name "GitHub Actions" git config user.email "actions@github.com" - - git add packages/sdk/python/pyproject.toml packages/sdk/python/uv.lock - if ! git diff --staged --quiet; then - git commit -m "chore(sdk-python): v${NEW_VERSION}" - git push origin HEAD:main + if git show-ref --verify --quiet "refs/tags/sdk-python-v${NEW_VERSION}"; then + TAG_COMMIT=$(git rev-list -n 1 "refs/tags/sdk-python-v${NEW_VERSION}") + if [ "$TAG_COMMIT" != "$GITHUB_SHA" ]; then + echo "ERROR: sdk-python-v${NEW_VERSION} belongs to ${TAG_COMMIT}, not ${GITHUB_SHA}" >&2 + exit 1 + fi + echo "Python SDK tag is already reserved by this source commit" + exit 0 fi - git tag -a "sdk-python-v${NEW_VERSION}" -m "sdk-python v${NEW_VERSION}" - git push origin "sdk-python-v${NEW_VERSION}" + git push origin "refs/tags/sdk-python-v${NEW_VERSION}:refs/tags/sdk-python-v${NEW_VERSION}" + + - name: Publish to PyPI + if: github.ref == 'refs/heads/main' && github.event.inputs.dry_run != 'true' && steps.pypi-state.outputs.published != 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/sdk/python/dist - name: Create GitHub Release if: github.ref == 'refs/heads/main' && github.event.inputs.dry_run != 'true' - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: tag_name: sdk-python-v${{ steps.bump.outputs.new_version }} name: sdk-python-v${{ steps.bump.outputs.new_version }} @@ -204,15 +273,17 @@ jobs: - name: Summary if: always() run: | - echo "## Python SDK Publish Summary" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "**Version**: \`${{ steps.bump.outputs.new_version }}\`" >> "$GITHUB_STEP_SUMMARY" - echo "**Dry Run**: \`${{ github.event.inputs.dry_run }}\`" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then - echo "Dry run completed. Built and checked dist, but did not publish or tag." >> "$GITHUB_STEP_SUMMARY" - elif [ "${{ github.ref }}" != "refs/heads/main" ]; then - echo "Build and checks completed on a non-main ref; publish, tag, and release were skipped." >> "$GITHUB_STEP_SUMMARY" - else - echo "Published to PyPI and created tag \`sdk-python-v${{ steps.bump.outputs.new_version }}\`." >> "$GITHUB_STEP_SUMMARY" - fi + { + echo "## Python SDK Publish Summary" + echo "" + echo "**Version**: \`${{ steps.bump.outputs.new_version }}\`" + echo "**Dry Run**: \`${{ github.event.inputs.dry_run }}\`" + echo "" + if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then + echo "Dry run completed. Built and checked dist, but did not publish or tag." + elif [ "${{ github.ref }}" != "refs/heads/main" ]; then + echo "Build and checks completed on a non-main ref; publish, tag, and release were skipped." + else + echo "Published to PyPI and created tag \`sdk-python-v${{ steps.bump.outputs.new_version }}\`." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 05957d4a..69631e7c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -81,6 +81,7 @@ jobs: outputs: new_version: ${{ steps.bump.outputs.new_version }} is_prerelease: ${{ steps.bump.outputs.is_prerelease }} + is_recovery: ${{ steps.bump.outputs.is_recovery }} steps: - name: Validate release mode @@ -131,16 +132,35 @@ jobs: CUSTOM_VERSION: ${{ github.event.inputs.custom_version }} VERSION_TYPE: ${{ github.event.inputs.version }} PREID: ${{ github.event.inputs.preid }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail MANIFEST_VERSION=$(node -p "require('./package.json').version") - LATEST_TAG=$(git -c versionsort.suffix=- for-each-ref \ + LATEST_TAG="" + while IFS= read -r CANDIDATE_TAG; do + RELEASE_ERROR=$(mktemp) + if gh api \ + "repos/${GITHUB_REPOSITORY}/releases/tags/${CANDIDATE_TAG}" \ + --silent 2>"$RELEASE_ERROR"; then + LATEST_TAG="$CANDIDATE_TAG" + rm -f "$RELEASE_ERROR" + break + fi + if grep -q 'HTTP 404' "$RELEASE_ERROR"; then + rm -f "$RELEASE_ERROR" + continue + fi + cat "$RELEASE_ERROR" >&2 + rm -f "$RELEASE_ERROR" + echo "ERROR: could not verify completed release ${CANDIDATE_TAG}" >&2 + exit 1 + done < <(git -c versionsort.suffix=- for-each-ref \ --sort=-version:refname \ --format='%(refname:short)' \ - 'refs/tags/v[0-9]*' | sed -n '1p') + 'refs/tags/v[0-9]*') if [ -z "$LATEST_TAG" ]; then - echo "ERROR: no release tag exists; cannot derive the release baseline" >&2 + echo "ERROR: no completed GitHub release exists; cannot derive the release baseline" >&2 exit 1 fi @@ -164,8 +184,15 @@ jobs: NEW_VERSION=$(node -p "require('./package.json').version") if git show-ref --verify --quiet "refs/tags/v${NEW_VERSION}"; then - echo "ERROR: release tag v${NEW_VERSION} already exists" >&2 - exit 1 + TAG_COMMIT=$(git rev-list -n 1 "refs/tags/v${NEW_VERSION}") + if [ "$TAG_COMMIT" != "$GITHUB_SHA" ]; then + echo "ERROR: release tag v${NEW_VERSION} already exists at ${TAG_COMMIT}, not ${GITHUB_SHA}" >&2 + exit 1 + fi + echo "Recovering release v${NEW_VERSION} reserved by this source commit" + echo "is_recovery=true" >> "$GITHUB_OUTPUT" + else + echo "is_recovery=false" >> "$GITHUB_OUTPUT" fi echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "New version: $NEW_VERSION" @@ -380,6 +407,15 @@ jobs: set -euo pipefail git config user.name "GitHub Actions" git config user.email "actions@github.com" + if git show-ref --verify --quiet "refs/tags/v${NEW_VERSION}"; then + TAG_COMMIT=$(git rev-list -n 1 "refs/tags/v${NEW_VERSION}") + if [ "$TAG_COMMIT" != "$GITHUB_SHA" ]; then + echo "ERROR: release tag v${NEW_VERSION} belongs to ${TAG_COMMIT}, not ${GITHUB_SHA}" >&2 + exit 1 + fi + echo "Release tag v${NEW_VERSION} is already reserved by this source commit" + exit 0 + fi git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}" git push origin "refs/tags/v${NEW_VERSION}:refs/tags/v${NEW_VERSION}" @@ -478,6 +514,7 @@ jobs: working-directory: ${{ matrix.path }} env: NPM_TAG: ${{ github.event.inputs.tag }} + RELEASE_RECOVERY: ${{ needs.build.outputs.is_recovery }} run: | PACKAGE_NAME=$(node -p "require('./package.json').name") echo "Dry run - would publish ${PACKAGE_NAME}" @@ -488,7 +525,28 @@ jobs: working-directory: ${{ matrix.path }} env: NPM_TAG: ${{ github.event.inputs.tag }} - run: npm publish --access public --provenance --tag "$NPM_TAG" --ignore-scripts + run: | + set -euo pipefail + PACKAGE_NAME=$(node -p "require('./package.json').name") + PACKAGE_VERSION=$(node -p "require('./package.json').version") + VIEW_ERROR=$(mktemp) + if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version --json >/dev/null 2>"$VIEW_ERROR"; then + rm -f "$VIEW_ERROR" + if [ "$RELEASE_RECOVERY" != "true" ]; then + echo "ERROR: ${PACKAGE_NAME}@${PACKAGE_VERSION} exists without a same-commit tag reservation" >&2 + exit 1 + fi + echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published; continuing recovery" + exit 0 + fi + if ! grep -q 'E404' "$VIEW_ERROR"; then + cat "$VIEW_ERROR" >&2 + rm -f "$VIEW_ERROR" + echo "ERROR: could not determine whether ${PACKAGE_NAME}@${PACKAGE_VERSION} is published" >&2 + exit 1 + fi + rm -f "$VIEW_ERROR" + npm publish --access public --provenance --tag "$NPM_TAG" --ignore-scripts # Publish single package (when not 'all') publish-single: @@ -570,6 +628,7 @@ jobs: working-directory: ${{ steps.resolve-package.outputs.path }} env: NPM_TAG: ${{ github.event.inputs.tag }} + RELEASE_RECOVERY: ${{ needs.build.outputs.is_recovery }} run: | PACKAGE_NAME=$(node -p "require('./package.json').name") echo "Dry run - would publish ${PACKAGE_NAME}" @@ -580,7 +639,28 @@ jobs: working-directory: ${{ steps.resolve-package.outputs.path }} env: NPM_TAG: ${{ github.event.inputs.tag }} - run: npm publish --access public --provenance --tag "$NPM_TAG" --ignore-scripts + run: | + set -euo pipefail + PACKAGE_NAME=$(node -p "require('./package.json').name") + PACKAGE_VERSION=$(node -p "require('./package.json').version") + VIEW_ERROR=$(mktemp) + if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version --json >/dev/null 2>"$VIEW_ERROR"; then + rm -f "$VIEW_ERROR" + if [ "$RELEASE_RECOVERY" != "true" ]; then + echo "ERROR: ${PACKAGE_NAME}@${PACKAGE_VERSION} exists without a same-commit tag reservation" >&2 + exit 1 + fi + echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published; continuing recovery" + exit 0 + fi + if ! grep -q 'E404' "$VIEW_ERROR"; then + cat "$VIEW_ERROR" >&2 + rm -f "$VIEW_ERROR" + echo "ERROR: could not determine whether ${PACKAGE_NAME}@${PACKAGE_VERSION} is published" >&2 + exit 1 + fi + rm -f "$VIEW_ERROR" + npm publish --access public --provenance --tag "$NPM_TAG" --ignore-scripts create-release: name: Create Release diff --git a/scripts/check-publish-workflow.sh b/scripts/check-publish-workflow.sh index 3c8c4605..ec411df6 100755 --- a/scripts/check-publish-workflow.sh +++ b/scripts/check-publish-workflow.sh @@ -3,7 +3,9 @@ set -euo pipefail workflow_file="${1:-.github/workflows/publish.yml}" +expected_push_count="${2:-1}" allowed_tag_push='git push origin "refs/tags/v${NEW_VERSION}:refs/tags/v${NEW_VERSION}"' +allowed_python_tag_push='git push origin "refs/tags/sdk-python-v${NEW_VERSION}:refs/tags/sdk-python-v${NEW_VERSION}"' push_count=0 while IFS= read -r source_line; do @@ -15,23 +17,33 @@ while IFS= read -r source_line; do ;; esac - if [[ "$line" == git\ add* || "$line" == git\ commit* ]]; then - echo "publish workflow check failed: release workflow may not create commits: $line" >&2 + if [[ "$line" =~ (^|[[:space:]:;|\&])git([[:space:]]+[^[:space:];|\&]+)*[[:space:]]+(add|commit|push)([[:space:];|\&]|$) ]]; then + if [[ "$line" == "$allowed_tag_push" || "$line" == "$allowed_python_tag_push" ]]; then + push_count=$((push_count + 1)) + continue + fi + echo "publish workflow check failed: only the explicit release-tag refspec may mutate git state: $line" >&2 exit 1 fi +done < "$workflow_file" - if [[ "$line" == git\ push* ]]; then - if [[ "$line" != "$allowed_tag_push" ]]; then - echo "publish workflow check failed: only the explicit release-tag refspec may be pushed: $line" >&2 +case "$expected_push_count" in + allow-zero) + if [[ "$push_count" -gt 1 ]]; then + echo "publish workflow check failed: expected at most one explicit release-tag push, found $push_count" >&2 exit 1 fi - push_count=$((push_count + 1)) - fi -done < "$workflow_file" - -if [[ "$push_count" -ne 1 ]]; then - echo "publish workflow check failed: expected exactly one explicit release-tag push, found $push_count" >&2 - exit 1 -fi + ;; + 1) + if [[ "$push_count" -ne 1 ]]; then + echo "publish workflow check failed: expected exactly one explicit release-tag push, found $push_count" >&2 + exit 1 + fi + ;; + *) + echo "publish workflow check failed: unsupported expected push count: $expected_push_count" >&2 + exit 1 + ;; +esac echo "publish workflow check passed" diff --git a/scripts/test-check-publish-workflow.sh b/scripts/test-check-publish-workflow.sh new file mode 100755 index 00000000..119b42a6 --- /dev/null +++ b/scripts/test-check-publish-workflow.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root=$(cd "$(dirname "$0")/.." && pwd) +checker="$repo_root/scripts/check-publish-workflow.sh" +fixture_dir=$(mktemp -d) +trap 'rm -rf "$fixture_dir"' EXIT + +allowed='git push origin "refs/tags/v${NEW_VERSION}:refs/tags/v${NEW_VERSION}"' + +assert_rejected() { + local name=$1 + local bypass=$2 + local fixture="$fixture_dir/$name.yml" + printf 'run: |\n %s\n %s\n' "$allowed" "$bypass" > "$fixture" + if "$checker" "$fixture" >/dev/null 2>&1; then + echo "publish workflow checker accepted forbidden form: $bypass" >&2 + exit 1 + fi +} + +valid_fixture="$fixture_dir/valid.yml" +printf 'run: |\n %s\n' "$allowed" > "$valid_fixture" +"$checker" "$valid_fixture" >/dev/null + +assert_rejected run-prefix 'run: git push origin main' +assert_rejected command-prefix 'command git push origin main' +assert_rejected config-prefix 'git -c push.default=current push origin main' +assert_rejected add-command 'env git add package.json' +assert_rejected commit-command 'command git commit -m release' + +echo "publish workflow checker tests passed"