Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 109 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,24 +1,111 @@
name: Release & Publish

run-name: "Release ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} (${{ github.event_name }})"

on:
# Normal release path: pushing a version tag.
push:
tags:
- 'v*'
# Manual path for a tag that already exists (for example, a release whose tag-push run used an
# older version of this workflow). It must be dispatched from `main`. Both the validation and
# build jobs check out the requested tag, never the `main` tree.
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to validate, build and publish (for example v1.2.0). Dispatch from main."
required: true
type: string

permissions:
contents: read

env:
# The release tag for this run: the pushed tag, or the `tag` input of a manual dispatch.
# Shell steps read it only through this environment variable, never through `${{ }}`
# interpolation inside a script.
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}

# Never run two releases of the same tag at the same time (for example a tag push and a dispatch).
concurrency:
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
cancel-in-progress: false

jobs:
validate:
name: Validate Version & Run Release Test Suite
runs-on: ubuntu-latest
# Test job: read-only, no OIDC token, no publishing credentials.
permissions:
contents: read
outputs:
commit: ${{ steps.release-commit.outputs.commit }}

steps:
- name: Checkout repository
- name: Check release trigger and tag name
run: |
set -euo pipefail
tag_pattern='^v[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?(\.post[0-9]+)?$'
if ! [[ "${RELEASE_TAG}" =~ $tag_pattern ]]; then
echo "::error::Release tag must look like v1.2.3 (optionally with a1/b1/rc1 or .post1); refusing to release."
exit 1
fi
echo "event=${GITHUB_EVENT_NAME} ref=${GITHUB_REF} release_tag=${RELEASE_TAG}"
case "${GITHUB_EVENT_NAME}" in
push)
if [ "${GITHUB_REF}" != "refs/tags/${RELEASE_TAG}" ]; then
echo "::error::Tag-push run for ${GITHUB_REF} does not match release tag ${RELEASE_TAG}."
exit 1
fi
;;
workflow_dispatch)
if [ "${GITHUB_REF}" != "refs/heads/main" ]; then
echo "::error::Manual releases must be dispatched from main, not ${GITHUB_REF}."
exit 1
fi
;;
*)
echo "::error::Unsupported release trigger: ${GITHUB_EVENT_NAME}."
exit 1
;;
esac

- name: Checkout the release tag (full history and tags)
uses: actions/checkout@v4
with:
# Full history and all tags: the test suite verifies historical tags and commits
# (for example v0.1.0-baseline), which a shallow checkout does not contain.
ref: refs/tags/${{ env.RELEASE_TAG }}
fetch-depth: 0
persist-credentials: false

- name: Verify the checked-out commit is the release tag
id: release-commit
run: |
set -euo pipefail
if ! tag_commit="$(git rev-parse --verify --quiet "refs/tags/${RELEASE_TAG}^{commit}")"; then
echo "::error::Tag ${RELEASE_TAG} does not exist."
exit 1
fi
head_commit="$(git rev-parse HEAD)"
if [ "${tag_commit}" != "${head_commit}" ]; then
echo "::error::Checked-out HEAD ${head_commit} is not tag ${RELEASE_TAG} (${tag_commit})."
exit 1
fi
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
pushed_commit="$(git rev-parse --verify --quiet "${GITHUB_SHA}^{commit}")" || pushed_commit=""
if [ "${pushed_commit}" != "${head_commit}" ]; then
echo "::error::Tag ${RELEASE_TAG} now points to ${head_commit}, not the pushed commit ${GITHUB_SHA}."
exit 1
fi
else
if ! git merge-base --is-ancestor "${head_commit}" refs/remotes/origin/main; then
echo "::error::Tag ${RELEASE_TAG} (${head_commit}) is not on main; refusing a manual release."
exit 1
fi
fi
echo "Release ${RELEASE_TAG} -> ${head_commit}"
echo "commit=${head_commit}" >> "$GITHUB_OUTPUT"

- name: Set up Python
uses: actions/setup-python@v5
Expand All @@ -32,13 +119,11 @@ jobs:
pip install -e ".[dev,mcp,api]"

- name: Verify tag, pyproject version and package __version__ match
env:
TAG_NAME: ${{ github.ref_name }}
run: |
python - <<'EOF'
import os, sys, tomllib
import reponeedle
tag = os.environ["TAG_NAME"]
tag = os.environ["RELEASE_TAG"]
tag_version = tag[1:] if tag.startswith("v") else tag
project_version = tomllib.load(open("pyproject.toml", "rb"))["project"]["version"]
package_version = reponeedle.__version__
Expand Down Expand Up @@ -70,10 +155,28 @@ jobs:
contents: read

steps:
- name: Checkout repository
- name: Checkout the release tag (full history and tags)
uses: actions/checkout@v4
with:
ref: refs/tags/${{ env.RELEASE_TAG }}
fetch-depth: 0
persist-credentials: false

- name: Verify the checked-out commit is the validated release commit
env:
VALIDATED_COMMIT: ${{ needs.validate.outputs.commit }}
run: |
set -euo pipefail
if ! tag_commit="$(git rev-parse --verify --quiet "refs/tags/${RELEASE_TAG}^{commit}")"; then
echo "::error::Tag ${RELEASE_TAG} does not exist."
exit 1
fi
head_commit="$(git rev-parse HEAD)"
if [ -z "${VALIDATED_COMMIT}" ] || [ "${head_commit}" != "${tag_commit}" ] || [ "${head_commit}" != "${VALIDATED_COMMIT}" ]; then
echo "::error::Build checkout ${head_commit} does not match tag ${RELEASE_TAG} (${tag_commit}) and the validated commit (${VALIDATED_COMMIT:-none})."
exit 1
fi
echo "Building ${RELEASE_TAG} from ${head_commit}"

- name: Set up Python
uses: actions/setup-python@v5
Expand All @@ -95,10 +198,8 @@ jobs:
python -m twine check --strict dist/*

- name: Verify built artifacts carry the tagged version
env:
TAG_NAME: ${{ github.ref_name }}
run: |
VERSION="${TAG_NAME#v}"
VERSION="${RELEASE_TAG#v}"
test -f "dist/reponeedle-${VERSION}-py3-none-any.whl" || { echo "Wheel for ${VERSION} not found"; ls dist; exit 1; }
test -f "dist/reponeedle-${VERSION}.tar.gz" || { echo "sdist for ${VERSION} not found"; ls dist; exit 1; }

Expand Down
81 changes: 57 additions & 24 deletions docs/pypi_trusted_publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,45 +49,57 @@ In GitHub, protect the deployment target using the environment specified in `rel
3. Click **New environment**.
4. Set the name to exactly `pypi` and click **Configure environment**.
5. *(Recommended Security Rules)*:
- **Deployment branches and tags**: Select **Selected tags** and specify rule `v*` (ensuring only release tags can deploy).
- **Required reviewers**: Optionally add `krishika08` for explicit deployment confirmation.
- **Deployment branches and tags**: Select **Selected branches and tags** and add the tag rule `v*` and the branch rule `main`. Tag pushes deploy from `refs/tags/v*`; a [manual release of an existing tag](#6-manually-releasing-an-existing-tag) runs from `refs/heads/main`. A tags-only rule would block manual releases at the deployment gate.
- **Required reviewers**: Optionally add `krishika08` for explicit deployment confirmation. Every publish, tag-push or manual, then waits for approval.

---

## 4. Step 3: Automated Workflow Architecture (`.github/workflows/release.yml`)

The release pipeline consists of two decoupled jobs:
The release pipeline has three jobs. Each job starts only after the previous one succeeds.

```
[ Git Tag: v* ]
[ Tag push v* | manual dispatch from main with tag input ]
│
▼
┌──────────────────────────────────────────────┐
│ Job 1: build │
│ - Checkout full git history │
│ - Set up Python 3.11 │
│ - python -m build │
│ - python -m twine check --strict dist/* │
│ - sha256sum * > SHA256SUMS │
│ - Upload artifacts (packages & checksums) │
└──────────────────────┬───────────────────────┘
│ Validated Artifacts
▼
┌──────────────────────────────────────────────┐
│ Job 2: publish │
│ - Environment: pypi │
│ - Permissions: id-token: write │
│ - Download validated distributions │
│ - pypa/gh-action-pypi-publish@release/v1 │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Job 1: validate (contents: read) │
│ - Check trigger and tag format; dispatch only from main │
│ - Checkout refs/tags/<tag> with full history and tags │
│ - Verify HEAD == tag commit (== pushed commit for push; │
│ tag commit on main for dispatch) │
│ - Verify tag == pyproject version == __version__ │
│ - Full test suite, strict MCP/security suites │
└──────────────────────────┬───────────────────────────────┘
│ validated commit SHA
▼
┌──────────────────────────────────────────────────────────┐
│ Job 2: build (contents: read) │
│ - Checkout refs/tags/<tag> with full history and tags │
│ - Verify HEAD == tag commit == validated commit │
│ - python -m build; twine check --strict │
│ - Verify artifact file names carry the tag version │
│ - sha256sum > SHA256SUMS; upload artifacts │
└──────────────────────────┬───────────────────────────────┘
│ validated artifacts
▼
┌──────────────────────────────────────────────────────────┐
│ Job 3: publish │
│ - Environment: pypi │
│ - Permissions: id-token: write, contents: read │
│ - Download artifacts from build (no checkout) │
│ - pypa/gh-action-pypi-publish@release/v1 │
└──────────────────────────────────────────────────────────┘
```

### Security & Integrity Controls:
- **Zero API Tokens**: No API tokens, passwords, or secrets are stored in GitHub repository secrets.
- **Strict Triggering**: Triggers exclusively on `push` of tags matching `v*`. Normal branch pushes and Pull Requests never invoke this workflow.
- **Least-Privilege Permissions**: Top-level permissions are read-only (`contents: read`). The `publish` job explicitly requests only `id-token: write` and `contents: read`.
- **Restricted Triggering**: The workflow runs only on a push of a tag matching `v*`, or on a manual `workflow_dispatch` from `main` naming an existing release tag. Normal branch pushes and pull requests never invoke it. A dispatch from any other branch, or with a tag that is malformed, does not exist or is not on `main`, fails in `validate`.
- **Exact Release Ref**: `validate` and `build` both check out `refs/tags/<tag>` (never the branch tree) with full history, verify that HEAD is the tag's commit, and `build` refuses to build anything other than the commit `validate` tested.
- **Least-Privilege Permissions**: Top-level permissions are read-only (`contents: read`). `validate` and `build` are read-only and do not persist checkout credentials. Only `publish` requests `id-token: write`.
- **Pre-Publication Twine Audit**: Distributions must pass `twine check --strict` before the publish job is scheduled.
- **Deterministic Checksums**: SHA-256 hashes are recorded in `checksums/SHA256SUMS` and emitted to the GitHub Step Summary.
- **No Concurrent Releases**: Runs for the same tag are serialised (`concurrency: release-<tag>`).

---

Expand All @@ -108,3 +120,24 @@ git push origin v1.1.0
```

GitHub Actions will automatically run `.github/workflows/release.yml`, build the validated distributions, exchange OIDC claims with PyPI, and publish `reponeedle==1.1.0` to PyPI.

---

## 6. Manually Releasing an Existing Tag

Use this when a release tag already exists but its tag-push run failed for a reason unrelated to the tagged code. For example, `v1.2.0` was pushed while `release.yml` still used a shallow checkout, which broke the history tests. Re-running that old run would reuse the old workflow definition, so dispatch a new run instead. Do **not** delete, move or recreate the tag.

```bash
# Dispatch from main, so that the current release.yml is used. The jobs check out the tag, not main.
gh workflow run release.yml --ref main -f tag=v1.2.0
```

Or in the GitHub UI: **Actions** → **Release & Publish** → **Run workflow**, keep **Branch: main**, and enter the tag.

The run is rejected unless all of these hold:
- it was dispatched from `main`;
- the tag looks like `vX.Y.Z` (optionally `aN`/`bN`/`rcN` and `.postN`) and exists;
- the tag's commit is on `main`;
- the tag, `pyproject.toml` and `reponeedle.__version__` at that commit agree.

PyPI Trusted Publishing accepts the run unchanged: the OIDC token still comes from `krishika08/RepoNeedle`, workflow `release.yml`, environment `pypi`. PyPI does not restrict the triggering event or ref. If the `pypi` environment has deployment branch/tag rules, they must allow `main` (see [Step 2](#3-step-2-configure-the-github-environment)).
Loading
Loading