diff --git a/.github/actions/dispatch-package-cd/action.yml b/.github/actions/dispatch-package-cd/action.yml deleted file mode 100644 index 0170ad45..00000000 --- a/.github/actions/dispatch-package-cd/action.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: Dispatch package CD workflow -description: > - Dispatch a package CD workflow for a tag, with an idempotency check so that - re-running the coordinator workflow does not trigger duplicate CD runs. - -inputs: - workflow_id: - description: The workflow file name to dispatch (e.g., cd-unxt.yml). - required: true - workflow_name: - description: - Human-readable workflow name for log messages (e.g., CD - unxt). - required: true - tag_ref: - description: The tag ref to dispatch the workflow on (e.g., unxt-v1.2.0). - required: true - tag_sha: - description: The commit SHA the tag points to. - required: true - trigger_cd: - description: > - Whether a new tag was just created ('true') or all tags pre-existed - ('false', i.e. a re-run). When 'false', an idempotency check is performed - before dispatching. - required: true - -runs: - using: composite - steps: - - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const workflowId = "${{ inputs.workflow_id }}"; - const workflowName = "${{ inputs.workflow_name }}"; - const tagRef = "${{ inputs.tag_ref }}"; - const tagSha = "${{ inputs.tag_sha }}"; - const shouldDispatch = "${{ inputs.trigger_cd }}" === "true"; - - if (!shouldDispatch) { - const existingRuns = await github.paginate( - github.rest.actions.listWorkflowRuns, - { - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: workflowId, - branch: tagRef, - per_page: 100, - }, - ); - - const hasMatchingRun = existingRuns.some( - (run) => { - const isMatchingTagSha = - run.head_branch === tagRef && run.head_sha === tagSha; - const isBlockingRun = - run.status !== "completed" || - (run.status === "completed" && run.conclusion === "success"); - - return isMatchingTagSha && isBlockingRun; - }, - ); - - if (hasMatchingRun) { - core.info( - `Skipping CD dispatch for ${tagRef}: a queued/in-progress/successful ${workflowName} run already exists for ${tagSha}.`, - ); - return; - } - - core.info( - `No queued/in-progress/successful ${workflowName} run found for ${tagRef} at ${tagSha}; dispatching recovery run.`, - ); - } - - try { - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: workflowId, - ref: tagRef, - }); - core.info(`Triggered ${workflowName} for tag ${tagRef}`); - } catch (error) { - core.setFailed( - `Failed to trigger ${workflowName} for tag ${tagRef}: ${error.message}` - ); - } diff --git a/.github/workflows/cd-unxt-api.yml b/.github/workflows/cd-unxt-api.yml index 7601a1ba..abd2f6f6 100644 --- a/.github/workflows/cd-unxt-api.yml +++ b/.github/workflows/cd-unxt-api.yml @@ -3,13 +3,8 @@ name: CD - unxt-api on: workflow_dispatch: push: - branches: - - main tags: - "unxt-api-v*" # Package-specific tags (unxt-api-vX.Y.Z) - paths: - - "packages/unxt-api/**" - - ".github/workflows/cd-unxt-api.yml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,11 +19,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # Build runs for manual dispatch, main pushes, and direct package tag pushes + # Build for a manual dispatch or a package tag push (release). if: | github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -43,9 +37,7 @@ jobs: ref: ${{ github.ref }} - name: Validate git tag (for tagged releases) - if: | - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || - (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/')) + if: startsWith(github.ref, 'refs/tags/') env: GITHUB_REF_VAR: ${{ github.ref }} run: | diff --git a/.github/workflows/cd-unxt-hypothesis.yml b/.github/workflows/cd-unxt-hypothesis.yml index 3e7b5729..5073c80d 100644 --- a/.github/workflows/cd-unxt-hypothesis.yml +++ b/.github/workflows/cd-unxt-hypothesis.yml @@ -3,13 +3,8 @@ name: CD - unxt-hypothesis on: workflow_dispatch: push: - branches: - - main tags: - "unxt-hypothesis-v*" # Package-specific tags (unxt-hypothesis-vX.Y.Z) - paths: - - "packages/unxt-hypothesis/**" - - ".github/workflows/cd-unxt-hypothesis.yml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,11 +19,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # Build runs for manual dispatch, main pushes, and direct package tag pushes. + # Build for a manual dispatch or a package tag push (release). if: | github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/.github/workflows/cd-unxt.yml b/.github/workflows/cd-unxt.yml index 97ebe624..c814c790 100644 --- a/.github/workflows/cd-unxt.yml +++ b/.github/workflows/cd-unxt.yml @@ -3,15 +3,8 @@ name: CD - unxt on: workflow_dispatch: push: - branches: - - main tags: - "unxt-v*" # Package-specific tags (unxt-vX.Y.Z) - paths: - - "src/**" - - "pyproject.toml" - - "README.md" - - ".github/workflows/cd-unxt.yml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -26,11 +19,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # Build runs for manual dispatch, main pushes, and direct package tag pushes + # Build for a manual dispatch or a package tag push (release). if: | github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -45,7 +37,7 @@ jobs: ref: ${{ github.ref }} - name: Validate git tag (for tagged releases) - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/') env: GITHUB_REF_VAR: ${{ github.ref }} run: | diff --git a/.github/workflows/cd-unxts-api.yml b/.github/workflows/cd-unxts-api.yml index 3b14a55b..3b28e2cf 100644 --- a/.github/workflows/cd-unxts-api.yml +++ b/.github/workflows/cd-unxts-api.yml @@ -3,13 +3,8 @@ name: CD - unxts-api on: workflow_dispatch: push: - branches: - - main tags: - "unxts-api-v*" # Package-specific tags (unxts-api-vX.Y.Z) - paths: - - "packages/unxts.api/**" - - ".github/workflows/cd-unxts-api.yml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,11 +19,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # Build runs for manual dispatch, main pushes, and direct package tag pushes + # Build for a manual dispatch or a package tag push (release). if: | github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -43,9 +37,7 @@ jobs: ref: ${{ github.ref }} - name: Validate git tag (for tagged releases) - if: | - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || - (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/')) + if: startsWith(github.ref, 'refs/tags/') env: GITHUB_REF_VAR: ${{ github.ref }} run: | diff --git a/.github/workflows/cd-unxts-hypothesis.yml b/.github/workflows/cd-unxts-hypothesis.yml index 9cf0b1f8..81277e23 100644 --- a/.github/workflows/cd-unxts-hypothesis.yml +++ b/.github/workflows/cd-unxts-hypothesis.yml @@ -3,13 +3,8 @@ name: CD - unxts-hypothesis on: workflow_dispatch: push: - branches: - - main tags: - "unxts-hypothesis-v*" # Package-specific tags (unxts-hypothesis-vX.Y.Z) - paths: - - "packages/unxts.hypothesis/**" - - ".github/workflows/cd-unxts-hypothesis.yml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,11 +19,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # Build runs for manual dispatch, main pushes, and direct package tag pushes + # Build for a manual dispatch or a package tag push (release). if: | github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -43,9 +37,7 @@ jobs: ref: ${{ github.ref }} - name: Validate git tag (for tagged releases) - if: | - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || - (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/')) + if: startsWith(github.ref, 'refs/tags/') env: GITHUB_REF_VAR: ${{ github.ref }} run: | diff --git a/.github/workflows/cd-unxts-interop-gala.yml b/.github/workflows/cd-unxts-interop-gala.yml index af5220ac..c8cf0711 100644 --- a/.github/workflows/cd-unxts-interop-gala.yml +++ b/.github/workflows/cd-unxts-interop-gala.yml @@ -3,13 +3,8 @@ name: CD - unxts-interop-gala on: workflow_dispatch: push: - branches: - - main tags: - "unxts-interop-gala-v*" # Package-specific tags (unxts-interop-gala-vX.Y.Z) - paths: - - "packages/unxts.interop.gala/**" - - ".github/workflows/cd-unxts-interop-gala.yml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,11 +19,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # Build runs for manual dispatch, main pushes, and direct package tag pushes + # Build for a manual dispatch or a package tag push (release). if: | github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -43,9 +37,7 @@ jobs: ref: ${{ github.ref }} - name: Validate git tag (for tagged releases) - if: | - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || - (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/')) + if: startsWith(github.ref, 'refs/tags/') env: GITHUB_REF_VAR: ${{ github.ref }} run: | diff --git a/.github/workflows/cd-unxts-interop-matplotlib.yml b/.github/workflows/cd-unxts-interop-matplotlib.yml index 422bfcf6..6ed83ee1 100644 --- a/.github/workflows/cd-unxts-interop-matplotlib.yml +++ b/.github/workflows/cd-unxts-interop-matplotlib.yml @@ -3,13 +3,8 @@ name: CD - unxts-interop-matplotlib on: workflow_dispatch: push: - branches: - - main tags: - "unxts-interop-matplotlib-v*" # Package-specific tags (unxts-interop-matplotlib-vX.Y.Z) - paths: - - "packages/unxts.interop.matplotlib/**" - - ".github/workflows/cd-unxts-interop-matplotlib.yml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,11 +19,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # Build runs for manual dispatch, main pushes, and direct package tag pushes + # Build for a manual dispatch or a package tag push (release). if: | github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -43,9 +37,7 @@ jobs: ref: ${{ github.ref }} - name: Validate git tag (for tagged releases) - if: | - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || - (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/')) + if: startsWith(github.ref, 'refs/tags/') env: GITHUB_REF_VAR: ${{ github.ref }} run: | diff --git a/.github/workflows/cd-unxts-interop-xarray.yml b/.github/workflows/cd-unxts-interop-xarray.yml index 3c5d49f4..2a325187 100644 --- a/.github/workflows/cd-unxts-interop-xarray.yml +++ b/.github/workflows/cd-unxts-interop-xarray.yml @@ -3,13 +3,8 @@ name: CD - unxts-interop-xarray on: workflow_dispatch: push: - branches: - - main tags: - "unxts-interop-xarray-v*" # Package-specific tags (unxts-interop-xarray-vX.Y.Z) - paths: - - "packages/unxts.interop.xarray/**" - - ".github/workflows/cd-unxts-interop-xarray.yml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,11 +19,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # Build runs for manual dispatch, main pushes, and direct package tag pushes + # Build for a manual dispatch or a package tag push (release). if: | github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -43,9 +37,7 @@ jobs: ref: ${{ github.ref }} - name: Validate git tag (for tagged releases) - if: | - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || - (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/')) + if: startsWith(github.ref, 'refs/tags/') env: GITHUB_REF_VAR: ${{ github.ref }} run: | diff --git a/.github/workflows/cd-unxts-linalg.yml b/.github/workflows/cd-unxts-linalg.yml index 8dec13f9..32f8526d 100644 --- a/.github/workflows/cd-unxts-linalg.yml +++ b/.github/workflows/cd-unxts-linalg.yml @@ -3,13 +3,8 @@ name: CD - unxts-linalg on: workflow_dispatch: push: - branches: - - main tags: - "unxts-linalg-v*" # Package-specific tags (unxts-linalg-vX.Y.Z) - paths: - - "packages/unxts.linalg/**" - - ".github/workflows/cd-unxts-linalg.yml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,11 +19,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # Build runs for manual dispatch, main pushes, and direct package tag pushes + # Build for a manual dispatch or a package tag push (release). if: | github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -43,9 +37,7 @@ jobs: ref: ${{ github.ref }} - name: Validate git tag (for tagged releases) - if: | - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || - (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/')) + if: startsWith(github.ref, 'refs/tags/') env: GITHUB_REF_VAR: ${{ github.ref }} run: | diff --git a/.github/workflows/cd-unxts-parametric.yml b/.github/workflows/cd-unxts-parametric.yml index 34bdf038..cec99eea 100644 --- a/.github/workflows/cd-unxts-parametric.yml +++ b/.github/workflows/cd-unxts-parametric.yml @@ -3,13 +3,8 @@ name: CD - unxts-parametric on: workflow_dispatch: push: - branches: - - main tags: - "unxts-parametric-v*" # Package-specific tags (unxts-parametric-vX.Y.Z) - paths: - - "packages/unxts.parametric/**" - - ".github/workflows/cd-unxts-parametric.yml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,11 +19,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # Build runs for manual dispatch, main pushes, and direct package tag pushes + # Build for a manual dispatch or a package tag push (release). if: | github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -43,9 +37,7 @@ jobs: ref: ${{ github.ref }} - name: Validate git tag (for tagged releases) - if: | - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || - (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/')) + if: startsWith(github.ref, 'refs/tags/') env: GITHUB_REF_VAR: ${{ github.ref }} run: | diff --git a/.github/workflows/create-package-tags.yml b/.github/workflows/create-package-tags.yml index a10e76e3..a159fc4f 100644 --- a/.github/workflows/create-package-tags.yml +++ b/.github/workflows/create-package-tags.yml @@ -1,8 +1,20 @@ name: Create Package Tags -# When a shared vX.Y.0 tag is pushed, automatically create -# package-specific tags (unxt-vX.Y.0, unxt-api-vX.Y.0, unxt-hypothesis-vX.Y.0) -# This allows coordinated releases while keeping each package with its own tag. +# When a shared vX.Y.0 tag is pushed, automatically create package-specific +# tags (unxt-vX.Y.0, unxt-api-vX.Y.0, unxts-linalg-vX.Y.0, ...). +# +# The package tags are pushed with a **GitHub App token**, not the default +# GITHUB_TOKEN. This matters for two reasons: +# 1. GITHUB_TOKEN is read-only in this repo, so it cannot push tags. +# 2. Tags pushed with GITHUB_TOKEN do not trigger further workflows; tags +# pushed with an App token do. That is what lets each package tag start +# its own `CD - ` workflow, so this job no longer has to dispatch +# them by hand (and therefore no longer needs `actions: write`). +# +# Requires a GitHub App installed on this repo with the "Contents: Read and +# write" repository permission, exposed through two repository secrets: +# - RELEASE_APP_ID +# - RELEASE_APP_PRIVATE_KEY on: push: @@ -20,30 +32,40 @@ jobs: create-package-tags: name: Create package-specific tags runs-on: ubuntu-latest + # GITHUB_TOKEN needs no write access: tag creation and the push below are + # authenticated with the App token minted in the first step. permissions: - actions: write # Needed to dispatch package CD workflows for bot-created tags - contents: write # Needed to create and push tags + contents: read steps: + - name: Mint a GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 + # Persist the App token so the `git push` below is authenticated with + # it (and therefore triggers the package CD workflows). + token: ${{ steps.app-token.outputs.token }} - name: Extract version from tag id: version run: | TAG=${GITHUB_REF#refs/tags/} - # Remove 'v' prefix to get version VERSION=${TAG#v} - echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Validate that this is a .0 release run: | TAG="${{ steps.version.outputs.tag }}" VERSION="${{ steps.version.outputs.version }}" - # Validate coordinator tag format: must be exactly vX.Y.0 (no suffixes like -rc1) + # Coordinator tags must be exactly vX.Y.0 (no suffixes like -rc1). if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.0$ ]]; then echo "ERROR: Coordinator tag must match pattern ^v[0-9]+\.[0-9]+\.0$ (e.g., v1.2.0)" echo "Invalid tag: $TAG" @@ -51,321 +73,73 @@ jobs: echo "Coordinator tags must be for .0 releases only (vX.Y.0)." echo "Pre-release tags (e.g., v1.2.0-rc1) are not supported." echo "" - echo "For bug-fix releases (X.Y.Z where Z > 0) or pre-releases, use package-specific tags:" - echo " - unxt-v${VERSION}" - echo " - unxt-api-v${VERSION}" - echo " - unxt-hypothesis-v${VERSION}" - echo " - unxts-api-v${VERSION}" - echo " - unxts-hypothesis-v${VERSION}" - echo " - unxts-interop-gala-v${VERSION}" - echo " - unxts-interop-matplotlib-v${VERSION}" - echo " - unxts-interop-xarray-v${VERSION}" - echo " - unxts-parametric-v${VERSION}" - echo " - unxts-linalg-v${VERSION}" + echo "For bug-fix releases (X.Y.Z where Z > 0) or pre-releases, push a" + echo "package-specific tag directly (e.g., unxt-v${VERSION})." exit 1 fi echo "✅ Valid coordinator tag: $TAG" - name: Create and push package-specific tags - id: create_tags run: | VERSION="${{ steps.version.outputs.version }}" TAG="${{ steps.version.outputs.tag }}" - UNXT_TAG_CREATED="false" - # Configure git git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - # Fetch all tags to check for existing package tags - git fetch --tags + # Fetch existing tags so we can detect (and safely re-run) partial runs. + git fetch --tags --force - # Get the commit SHA of the coordinator tag (works for annotated/lightweight tags) + # The commit the coordinator tag points to (annotated or lightweight). COORDINATOR_SHA=$(git rev-list -n 1 "$TAG") echo "Coordinator tag $TAG points to commit: $COORDINATOR_SHA" - # Define package tags to create PACKAGES=("unxt" "unxt-api" "unxt-hypothesis" "unxts-api" "unxts-hypothesis" "unxts-interop-gala" "unxts-interop-matplotlib" "unxts-interop-xarray" "unxts-parametric" "unxts-linalg") TAGS_TO_PUSH=() - UNXT_API_TAG_CREATED="false" - UNXT_HYPOTHESIS_TAG_CREATED="false" - UNXTS_API_TAG_CREATED="false" - UNXTS_HYPOTHESIS_TAG_CREATED="false" - UNXTS_INTEROP_GALA_TAG_CREATED="false" - UNXTS_INTEROP_MATPLOTLIB_TAG_CREATED="false" - UNXTS_INTEROP_XARRAY_TAG_CREATED="false" - UNXTS_PARAMETRIC_TAG_CREATED="false" - UNXTS_LINALG_TAG_CREATED="false" - - # Check and create each package tag for PACKAGE in "${PACKAGES[@]}"; do PACKAGE_TAG="${PACKAGE}-v${VERSION}" if git rev-parse "$PACKAGE_TAG" >/dev/null 2>&1; then - # Tag exists - verify it points at the coordinator tag's commit + # Tag exists already: it must point at the coordinator's commit. EXISTING_SHA=$(git rev-list -n 1 "$PACKAGE_TAG") - if [ "$EXISTING_SHA" = "$COORDINATOR_SHA" ]; then - echo "✅ Tag $PACKAGE_TAG already exists and points to correct commit ($EXISTING_SHA)" + echo "✅ Tag $PACKAGE_TAG already exists and points to the correct commit ($EXISTING_SHA)" else - echo "ERROR: Tag $PACKAGE_TAG already exists but points to wrong commit" + echo "ERROR: Tag $PACKAGE_TAG already exists but points to the wrong commit" echo " Expected (coordinator tag $TAG): $COORDINATOR_SHA" echo " Actual ($PACKAGE_TAG): $EXISTING_SHA" echo "" - echo "This indicates a bad state - the coordinator tag was likely recreated" - echo "on a new commit, but old package tags still point to the old commit." - echo "" - echo "To fix this, manually delete and recreate the package tag:" - echo " git tag -d $PACKAGE_TAG" + echo "Delete and recreate the package tag at the coordinator commit:" echo " git push origin :refs/tags/$PACKAGE_TAG" - echo " git tag $PACKAGE_TAG $COORDINATOR_SHA -m \"Release $PACKAGE ${VERSION}\"" - echo " git push origin $PACKAGE_TAG" + echo " git tag -f $PACKAGE_TAG $COORDINATOR_SHA && git push --force origin $PACKAGE_TAG" exit 1 fi else echo "📝 Creating tag $PACKAGE_TAG" git tag "$PACKAGE_TAG" "$COORDINATOR_SHA" -m "Release $PACKAGE ${VERSION} (auto-created from ${TAG})" TAGS_TO_PUSH+=("$PACKAGE_TAG") - if [ "$PACKAGE" = "unxt" ]; then - UNXT_TAG_CREATED="true" - fi - if [ "$PACKAGE" = "unxt-api" ]; then - UNXT_API_TAG_CREATED="true" - fi - if [ "$PACKAGE" = "unxt-hypothesis" ]; then - UNXT_HYPOTHESIS_TAG_CREATED="true" - fi - if [ "$PACKAGE" = "unxts-api" ]; then - UNXTS_API_TAG_CREATED="true" - fi - if [ "$PACKAGE" = "unxts-hypothesis" ]; then - UNXTS_HYPOTHESIS_TAG_CREATED="true" - fi - if [ "$PACKAGE" = "unxts-interop-gala" ]; then - UNXTS_INTEROP_GALA_TAG_CREATED="true" - fi - if [ "$PACKAGE" = "unxts-interop-matplotlib" ]; then - UNXTS_INTEROP_MATPLOTLIB_TAG_CREATED="true" - fi - if [ "$PACKAGE" = "unxts-interop-xarray" ]; then - UNXTS_INTEROP_XARRAY_TAG_CREATED="true" - fi - if [ "$PACKAGE" = "unxts-parametric" ]; then - UNXTS_PARAMETRIC_TAG_CREATED="true" - fi - if [ "$PACKAGE" = "unxts-linalg" ]; then - UNXTS_LINALG_TAG_CREATED="true" - fi fi done - # Push only newly created tags if [ ${#TAGS_TO_PUSH[@]} -eq 0 ]; then - echo "✅ All package tags already exist for version ${VERSION}" - echo " This is expected for workflow re-runs" - echo "trigger_unxt_cd=false" >> "$GITHUB_OUTPUT" - echo "unxt_tag=unxt-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxt_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxt_api_cd=false" >> "$GITHUB_OUTPUT" - echo "unxt_api_tag=unxt-api-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxt_api_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxt_hypothesis_cd=false" >> "$GITHUB_OUTPUT" - echo "unxt_hypothesis_tag=unxt-hypothesis-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxt_hypothesis_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_api_cd=false" >> "$GITHUB_OUTPUT" - echo "unxts_api_tag=unxts-api-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_api_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_hypothesis_cd=false" >> "$GITHUB_OUTPUT" - echo "unxts_hypothesis_tag=unxts-hypothesis-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_hypothesis_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_interop_gala_cd=false" >> "$GITHUB_OUTPUT" - echo "unxts_interop_gala_tag=unxts-interop-gala-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_interop_gala_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_interop_matplotlib_cd=false" >> "$GITHUB_OUTPUT" - echo "unxts_interop_matplotlib_tag=unxts-interop-matplotlib-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_interop_matplotlib_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_interop_xarray_cd=false" >> "$GITHUB_OUTPUT" - echo "unxts_interop_xarray_tag=unxts-interop-xarray-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_interop_xarray_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_parametric_cd=false" >> "$GITHUB_OUTPUT" - echo "unxts_parametric_tag=unxts-parametric-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_parametric_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_linalg_cd=false" >> "$GITHUB_OUTPUT" - echo "unxts_linalg_tag=unxts-linalg-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_linalg_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" + # Every package tag already exists, so nothing is pushed -- and a + # push is what starts each CD workflow. A re-run therefore does not + # re-trigger any build. Spell out the manual recovery path so a + # re-run does not look like it silently did nothing. + echo "::notice::All ${VERSION} package tags already exist; nothing to push, so no CD workflow was (re)triggered. See the log for how to restart a package's release." + echo "✅ All package tags already exist for version ${VERSION}; nothing to push." + echo "" + echo "This run pushed no tag, so it did not (re)trigger any CD workflow." + echo "If a package's release did not build/publish (e.g. it was cancelled)," + echo "restart it manually -- the tag already exists, so dispatch its CD" + echo "workflow on that tag:" + echo " gh workflow run cd-.yml --ref -v${VERSION}" + echo " # e.g. gh workflow run cd-unxts-linalg.yml --ref unxts-linalg-v${VERSION}" exit 0 fi echo "📤 Pushing ${#TAGS_TO_PUSH[@]} new tag(s): ${TAGS_TO_PUSH[*]}" git push origin "${TAGS_TO_PUSH[@]}" - - echo "✅ Successfully created and pushed package-specific tags for version ${VERSION}" - echo "trigger_unxt_cd=${UNXT_TAG_CREATED}" >> "$GITHUB_OUTPUT" - echo "unxt_tag=unxt-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxt_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxt_api_cd=${UNXT_API_TAG_CREATED}" >> "$GITHUB_OUTPUT" - echo "unxt_api_tag=unxt-api-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxt_api_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxt_hypothesis_cd=${UNXT_HYPOTHESIS_TAG_CREATED}" >> "$GITHUB_OUTPUT" - echo "unxt_hypothesis_tag=unxt-hypothesis-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxt_hypothesis_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_api_cd=${UNXTS_API_TAG_CREATED}" >> "$GITHUB_OUTPUT" - echo "unxts_api_tag=unxts-api-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_api_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_hypothesis_cd=${UNXTS_HYPOTHESIS_TAG_CREATED}" >> "$GITHUB_OUTPUT" - echo "unxts_hypothesis_tag=unxts-hypothesis-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_hypothesis_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_interop_gala_cd=${UNXTS_INTEROP_GALA_TAG_CREATED}" >> "$GITHUB_OUTPUT" - echo "unxts_interop_gala_tag=unxts-interop-gala-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_interop_gala_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_interop_matplotlib_cd=${UNXTS_INTEROP_MATPLOTLIB_TAG_CREATED}" >> "$GITHUB_OUTPUT" - echo "unxts_interop_matplotlib_tag=unxts-interop-matplotlib-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_interop_matplotlib_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_interop_xarray_cd=${UNXTS_INTEROP_XARRAY_TAG_CREATED}" >> "$GITHUB_OUTPUT" - echo "unxts_interop_xarray_tag=unxts-interop-xarray-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_interop_xarray_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_parametric_cd=${UNXTS_PARAMETRIC_TAG_CREATED}" >> "$GITHUB_OUTPUT" - echo "unxts_parametric_tag=unxts-parametric-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_parametric_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - echo "trigger_unxts_linalg_cd=${UNXTS_LINALG_TAG_CREATED}" >> "$GITHUB_OUTPUT" - echo "unxts_linalg_tag=unxts-linalg-v${VERSION}" >> "$GITHUB_OUTPUT" - echo "unxts_linalg_tag_sha=${COORDINATOR_SHA}" >> "$GITHUB_OUTPUT" - - - name: Trigger CD - unxt for auto-created unxt tag - if: | - steps.create_tags.outputs.trigger_unxt_cd == 'true' || - (steps.create_tags.outputs.unxt_tag != '' && - steps.create_tags.outputs.trigger_unxt_cd == 'false') - uses: ./.github/actions/dispatch-package-cd - with: - workflow_id: cd-unxt.yml - workflow_name: CD - unxt - tag_ref: ${{ steps.create_tags.outputs.unxt_tag }} - tag_sha: ${{ steps.create_tags.outputs.unxt_tag_sha }} - trigger_cd: ${{ steps.create_tags.outputs.trigger_unxt_cd }} - - - name: Trigger CD - unxt-api for auto-created unxt-api tag - if: | - steps.create_tags.outputs.trigger_unxt_api_cd == 'true' || - (steps.create_tags.outputs.unxt_api_tag != '' && - steps.create_tags.outputs.trigger_unxt_api_cd == 'false') - uses: ./.github/actions/dispatch-package-cd - with: - workflow_id: cd-unxt-api.yml - workflow_name: CD - unxt-api - tag_ref: ${{ steps.create_tags.outputs.unxt_api_tag }} - tag_sha: ${{ steps.create_tags.outputs.unxt_api_tag_sha }} - trigger_cd: ${{ steps.create_tags.outputs.trigger_unxt_api_cd }} - - - name: Trigger CD - unxt-hypothesis for auto-created unxt-hypothesis tag - if: | - steps.create_tags.outputs.trigger_unxt_hypothesis_cd == 'true' || - (steps.create_tags.outputs.unxt_hypothesis_tag != '' && - steps.create_tags.outputs.trigger_unxt_hypothesis_cd == 'false') - uses: ./.github/actions/dispatch-package-cd - with: - workflow_id: cd-unxt-hypothesis.yml - workflow_name: CD - unxt-hypothesis - tag_ref: ${{ steps.create_tags.outputs.unxt_hypothesis_tag }} - tag_sha: ${{ steps.create_tags.outputs.unxt_hypothesis_tag_sha }} - trigger_cd: - ${{ steps.create_tags.outputs.trigger_unxt_hypothesis_cd }} - - - name: Trigger CD - unxts-api - if: | - steps.create_tags.outputs.trigger_unxts_api_cd == 'true' || - (steps.create_tags.outputs.unxts_api_tag != '' && - steps.create_tags.outputs.trigger_unxts_api_cd == 'false') - uses: ./.github/actions/dispatch-package-cd - with: - workflow_id: cd-unxts-api.yml - workflow_name: CD - unxts-api - tag_ref: ${{ steps.create_tags.outputs.unxts_api_tag }} - tag_sha: ${{ steps.create_tags.outputs.unxts_api_tag_sha }} - trigger_cd: ${{ steps.create_tags.outputs.trigger_unxts_api_cd }} - - - name: Trigger CD - unxts-hypothesis - if: | - steps.create_tags.outputs.trigger_unxts_hypothesis_cd == 'true' || - (steps.create_tags.outputs.unxts_hypothesis_tag != '' && - steps.create_tags.outputs.trigger_unxts_hypothesis_cd == 'false') - uses: ./.github/actions/dispatch-package-cd - with: - workflow_id: cd-unxts-hypothesis.yml - workflow_name: CD - unxts-hypothesis - tag_ref: ${{ steps.create_tags.outputs.unxts_hypothesis_tag }} - tag_sha: ${{ steps.create_tags.outputs.unxts_hypothesis_tag_sha }} - trigger_cd: - ${{ steps.create_tags.outputs.trigger_unxts_hypothesis_cd }} - - - name: Trigger CD - unxts-interop-gala - if: | - steps.create_tags.outputs.trigger_unxts_interop_gala_cd == 'true' || - (steps.create_tags.outputs.unxts_interop_gala_tag != '' && - steps.create_tags.outputs.trigger_unxts_interop_gala_cd == 'false') - uses: ./.github/actions/dispatch-package-cd - with: - workflow_id: cd-unxts-interop-gala.yml - workflow_name: CD - unxts-interop-gala - tag_ref: ${{ steps.create_tags.outputs.unxts_interop_gala_tag }} - tag_sha: ${{ steps.create_tags.outputs.unxts_interop_gala_tag_sha }} - trigger_cd: - ${{ steps.create_tags.outputs.trigger_unxts_interop_gala_cd }} - - - name: Trigger CD - unxts-interop-matplotlib - if: | - steps.create_tags.outputs.trigger_unxts_interop_matplotlib_cd == 'true' || - (steps.create_tags.outputs.unxts_interop_matplotlib_tag != '' && - steps.create_tags.outputs.trigger_unxts_interop_matplotlib_cd == 'false') - uses: ./.github/actions/dispatch-package-cd - with: - workflow_id: cd-unxts-interop-matplotlib.yml - workflow_name: CD - unxts-interop-matplotlib - tag_ref: ${{ steps.create_tags.outputs.unxts_interop_matplotlib_tag }} - tag_sha: - ${{ steps.create_tags.outputs.unxts_interop_matplotlib_tag_sha }} - trigger_cd: - ${{ steps.create_tags.outputs.trigger_unxts_interop_matplotlib_cd }} - - - name: Trigger CD - unxts-interop-xarray - if: | - steps.create_tags.outputs.trigger_unxts_interop_xarray_cd == 'true' || - (steps.create_tags.outputs.unxts_interop_xarray_tag != '' && - steps.create_tags.outputs.trigger_unxts_interop_xarray_cd == 'false') - uses: ./.github/actions/dispatch-package-cd - with: - workflow_id: cd-unxts-interop-xarray.yml - workflow_name: CD - unxts-interop-xarray - tag_ref: ${{ steps.create_tags.outputs.unxts_interop_xarray_tag }} - tag_sha: ${{ steps.create_tags.outputs.unxts_interop_xarray_tag_sha }} - trigger_cd: - ${{ steps.create_tags.outputs.trigger_unxts_interop_xarray_cd }} - - - name: Trigger CD - unxts-parametric - if: | - steps.create_tags.outputs.trigger_unxts_parametric_cd == 'true' || - (steps.create_tags.outputs.unxts_parametric_tag != '' && - steps.create_tags.outputs.trigger_unxts_parametric_cd == 'false') - uses: ./.github/actions/dispatch-package-cd - with: - workflow_id: cd-unxts-parametric.yml - workflow_name: CD - unxts-parametric - tag_ref: ${{ steps.create_tags.outputs.unxts_parametric_tag }} - tag_sha: ${{ steps.create_tags.outputs.unxts_parametric_tag_sha }} - trigger_cd: - ${{ steps.create_tags.outputs.trigger_unxts_parametric_cd }} - - - name: Trigger CD - unxts-linalg - if: | - steps.create_tags.outputs.trigger_unxts_linalg_cd == 'true' || - (steps.create_tags.outputs.unxts_linalg_tag != '' && - steps.create_tags.outputs.trigger_unxts_linalg_cd == 'false') - uses: ./.github/actions/dispatch-package-cd - with: - workflow_id: cd-unxts-linalg.yml - workflow_name: CD - unxts-linalg - tag_ref: ${{ steps.create_tags.outputs.unxts_linalg_tag }} - tag_sha: ${{ steps.create_tags.outputs.unxts_linalg_tag_sha }} - trigger_cd: ${{ steps.create_tags.outputs.trigger_unxts_linalg_cd }} + echo "✅ Package tags pushed; each package's CD workflow starts automatically." diff --git a/RELEASING.md b/RELEASING.md index bedb091a..304017f8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -21,6 +21,34 @@ All releases are automated via GitHub Actions - **just push tags!** --- +## Prerequisites: GitHub App + +The coordinator workflow (`create-package-tags.yml`) pushes the package-specific tags with a **GitHub App token** rather than the default `GITHUB_TOKEN`. This is required because: + +- `GITHUB_TOKEN` is read-only in this repository and cannot push tags; and +- tags pushed with `GITHUB_TOKEN` do **not** trigger further workflows, whereas tags pushed with an App token do — which is how each package's `CD - ` build starts. + +One-time setup: + +1. Create a GitHub App with **Repository permissions → Contents: Read and write**. +2. Install it on `GalacticDynamics/unxt`. +3. Add two repository secrets: + - `RELEASE_APP_ID` — the App's ID + - `RELEASE_APP_PRIVATE_KEY` — a generated private key (PEM) + +Without these secrets the coordinator job fails at its "Mint a GitHub App token" step. + +### Access control + +Pushing a `v*` (or `-v*`) tag **is** a release trigger: it runs the coordinator, which mints the App token, and the package CD workflows then publish to PyPI. Anyone who can create those tags can therefore start a release, so restrict tag creation to the release maintainers: + +- Add a **tag ruleset** (Settings → Rules → Rulesets) covering `v*` and `*-v*` that restricts who may create/update those tags (and blocks deletion / non-fast-forward as desired). +- The `pypi` and `testpypi` deployment environments can additionally require a reviewer before their publish job runs, gating the actual upload. + +The App token is short-lived (minted per run) and scoped to **Contents: Read and write** on this repository only. The App private key lives in the `RELEASE_APP_PRIVATE_KEY` secret and is never exposed to workflow logs. + +--- + ## Quick Reference ### Release Types @@ -259,16 +287,15 @@ The release process is fully automated via GitHub Actions: 1. **Tag Creation** (`.github/workflows/create-package-tags.yml`): - Triggers on `v*` tags - Validates it's a `.0` release - - Creates package-specific tags automatically - - Pushes a tag for every workspace package + - Creates package-specific tags and pushes them with a **GitHub App token** (see [Prerequisites](#prerequisites-github-app)), so each package tag triggers its own build 2. **Package build** (`.github/workflows/cd-.yml`, "CD - \"): - - Triggers on that package's release tags (`unxt-v*`, `unxts-parametric-v*`, …), on pushes to `main` that touch the package (path-filtered), and on manual `workflow_dispatch` — so you'll also see a build run when a normal PR merges to `main` + - Triggers on that package's release tags (`unxt-v*`, `unxts-parametric-v*`, …) and on manual `workflow_dispatch` - Validates the tag with `scripts/validate_tag.py`, builds the package, and uploads it as a build artifact (runs unprivileged) 3. **Package publish** (`.github/workflows/cd-publish-.yml`, "CD Publish - \"): - Triggers via `workflow_run` when that package's build workflow completes - - Runs in a privileged context (so no repository code runs with publish permissions) and routes by what triggered the build: a verified release tag publishes to **both** TestPyPI and PyPI, while a trusted push to `main` (no tag) publishes to **TestPyPI only** + - Runs in a privileged context (so no repository code runs with publish permissions): a verified release tag publishes to **both** TestPyPI and PyPI. A tagless `workflow_dispatch` build publishes nowhere — it only verifies the package builds. 4. **Version Detection**: - hatch-vcs uses standard `git describe` with package-specific `--match` patterns