Skip to content

ci: harden workflows against script injection #8

ci: harden workflows against script injection

ci: harden workflows against script injection #8

Workflow file for this run

name: Cut Release
# The whole release runs here; nothing is cut from a laptop.
#
# 1. Trigger this workflow (Actions tab, or
# `gh workflow run cut-release.yml -f bump=minor`). The `prepare` job runs
# the existing `scripts/release.sh prepare`, which bumps the version, dates
# the [Unreleased] changelog section, pushes `release/vX.Y.Z` and opens the
# release PR. Auto-merge is enabled, so the PR merges once branch
# protection is satisfied (checks green, review approved).
# 2. When that PR merges, the `publish` job runs `scripts/release.sh publish`,
# which tags main `vX.Y.Z` and pushes the tag.
# 3. The tag push triggers publish.yml (PyPI) and release.yml (GitHub Release),
# exactly as before.
#
# Both jobs act as the Hotdata automation GitHub App rather than GITHUB_TOKEN:
# a PR opened or a tag pushed with GITHUB_TOKEN triggers no other workflows, so
# the release PR would run no checks and the tag would publish nothing.
on:
workflow_dispatch:
inputs:
bump:
description: 'patch | minor | major | explicit X.Y.Z'
required: true
default: patch
type: string
pull_request:
types: [closed]
branches: [main]
permissions: {}
concurrency:
group: cut-release
cancel-in-progress: false
jobs:
prepare:
name: Open release PR
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: Iv23liKBX2RYMoZIYuKa
private-key: ${{ secrets.HOTDATA_AUTOMATION_PRIVATE_KEY }}
owner: hotdata-dev
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
with:
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true # release.sh pushes the branch / tag
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.12'
# `release.sh prepare` refreshes uv.lock when uv is on PATH, so the lock
# the PR carries matches the bumped version.
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
- name: Configure git identity
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
slug="${APP_SLUG}[bot]"
id="$(gh api "/users/${slug}" --jq .id)"
git config user.name "$slug"
git config user.email "${id}+${slug}@users.noreply.github.com"
# release.sh resolves the default branch from origin/HEAD, which
# actions/checkout never sets; without this the script sees an empty
# branch name and git aborts on an empty pathspec.
- name: Point origin/HEAD at the default branch
run: git remote set-head origin --auto
- name: Prepare release
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
# Via env, not ${{ }} in the script: the input is free text and
# interpolating it into `run` would execute anything shell-shaped.
BUMP: ${{ inputs.bump }}
run: ./scripts/release.sh prepare "$BUMP"
# Branch protection on main gates the merge on the checks and the
# approving review, so this only merges once everything is green.
- name: Enable auto-merge on the release PR
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
branch="$(git rev-parse --abbrev-ref HEAD)"
number="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')"
[[ -n "$number" ]] || { echo "::error::no open PR for $branch"; exit 1; }
gh pr merge "$number" --squash --auto
echo "Release PR #$number opened from $branch with auto-merge enabled."
publish:
name: Tag release
if: >-
github.event_name == 'pull_request'
&& github.event.pull_request.merged == true
&& startsWith(github.event.pull_request.head.ref, 'release/v')
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: Iv23liKBX2RYMoZIYuKa
private-key: ${{ secrets.HOTDATA_AUTOMATION_PRIVATE_KEY }}
owner: hotdata-dev
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
with:
ref: main
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true # release.sh pushes the branch / tag
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.12'
- name: Configure git identity
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
slug="${APP_SLUG}[bot]"
id="$(gh api "/users/${slug}" --jq .id)"
git config user.name "$slug"
git config user.email "${id}+${slug}@users.noreply.github.com"
# release.sh resolves the default branch from origin/HEAD, which
# actions/checkout never sets; without this the script sees an empty
# branch name and git aborts on an empty pathspec.
- name: Point origin/HEAD at the default branch
run: git remote set-head origin --auto
- name: Tag and push
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: ./scripts/release.sh publish