mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-05 03:42:07 +00:00
Harden release workflow and regex against CodeQL findings
Address three code-scanning alerts on the PR: - Critical (actions/untrusted-checkout): split release.yaml into a read-only `prepare` job that checks out and runs repo code, and a privileged `publish` job (contents:write + OIDC) that never checks out repo code — it tags, releases, and invokes purely through the GitHub and AWS APIs. Also assert head_branch == main. - High x2 (py/polynomial-redos): rewrite the italic and link regexes in markdown_to_mrkdwn with possessive quantifiers and exclusive character classes so they run in linear time on adversarial input. Adds a regression test.
This commit is contained in:
parent
1ccedff872
commit
7e4458724f
3 changed files with 85 additions and 49 deletions
113
.github/workflows/release.yaml
vendored
113
.github/workflows/release.yaml
vendored
|
|
@ -1,36 +1,42 @@
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Runs after a successful Deploy. When the top of CHANGELOG.md names a version
|
# Runs after a successful Deploy. When the top of CHANGELOG.md names a version
|
||||||
# that has no tag yet, this tags it, publishes a GitHub Release with the notes,
|
# that has no Release yet, this tags it, publishes a GitHub Release with the
|
||||||
# and — for minor/major bumps only — invokes the release-notifier Lambda to
|
# notes, and — for minor/major bumps only — invokes the release-notifier Lambda
|
||||||
# announce it in Slack. Triggering on Deploy completion (not release:published /
|
# to announce it in Slack. Triggering on Deploy completion (not release:published
|
||||||
# tag push) is deliberate: GITHUB_TOKEN-created events do not start downstream
|
# / tag push) is deliberate: GITHUB_TOKEN-created events do not start downstream
|
||||||
# workflows, and gating on Deploy success means we never announce a version that
|
# workflows, and gating on Deploy success means we never announce a version that
|
||||||
# is not actually live.
|
# is not actually live.
|
||||||
|
#
|
||||||
|
# Two jobs by design (CodeQL actions/untrusted-checkout): the only job that
|
||||||
|
# checks out and runs repo code (`prepare`) is read-only and unprivileged; the
|
||||||
|
# job that holds write + OIDC (`publish`) never checks out repo code — it acts
|
||||||
|
# purely through the GitHub and AWS APIs.
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Deploy"]
|
workflows: ["Deploy"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
|
|
||||||
permissions:
|
# Serialize so back-to-back releases announce in order, never overlapping.
|
||||||
contents: write # create the tag + GitHub Release
|
|
||||||
id-token: write # OIDC to assume the notifier-invoke role
|
|
||||||
|
|
||||||
# Serialize so back-to-back releases announce in order (queued Deploys -> queued
|
|
||||||
# Releases), never overlapping.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-announce
|
group: release-announce
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
prepare:
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
# Deploy only runs on push to main, so head_sha is always a trusted main
|
||||||
|
# commit; assert head_branch == main to make that boundary explicit.
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
release: ${{ steps.rel.outputs.release }}
|
||||||
|
version: ${{ steps.rel.outputs.version }}
|
||||||
|
kind: ${{ steps.rel.outputs.kind }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
# The exact commit Deploy deployed — NOT the branch HEAD, which a later
|
|
||||||
# merge may have already moved past.
|
|
||||||
ref: ${{ github.event.workflow_run.head_sha }}
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
@ -41,6 +47,8 @@ jobs:
|
||||||
|
|
||||||
- name: Determine release
|
- name: Determine release
|
||||||
id: rel
|
id: rel
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
TOP=$(python scripts/changelog_cli.py top-version CHANGELOG.md)
|
TOP=$(python scripts/changelog_cli.py top-version CHANGELOG.md)
|
||||||
if [ -z "$TOP" ]; then
|
if [ -z "$TOP" ]; then
|
||||||
|
|
@ -52,52 +60,64 @@ jobs:
|
||||||
PREV="${PREV:-v0.0.0}"
|
PREV="${PREV:-v0.0.0}"
|
||||||
KIND=$(python scripts/changelog_cli.py bump-kind CHANGELOG.md "$PREV")
|
KIND=$(python scripts/changelog_cli.py bump-kind CHANGELOG.md "$PREV")
|
||||||
|
|
||||||
TAG_EXISTS=false
|
|
||||||
git rev-parse "v$TOP" >/dev/null 2>&1 && TAG_EXISTS=true
|
|
||||||
RELEASE_EXISTS=false
|
RELEASE_EXISTS=false
|
||||||
gh release view "v$TOP" >/dev/null 2>&1 && RELEASE_EXISTS=true
|
gh release view "v$TOP" >/dev/null 2>&1 && RELEASE_EXISTS=true
|
||||||
|
|
||||||
echo "version=$TOP" >> "$GITHUB_OUTPUT"
|
echo "version=$TOP" >> "$GITHUB_OUTPUT"
|
||||||
echo "kind=$KIND" >> "$GITHUB_OUTPUT"
|
echo "kind=$KIND" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT"
|
# Gate the publish job on a clean bump whose Release isn't published yet.
|
||||||
echo "release_exists=$RELEASE_EXISTS" >> "$GITHUB_OUTPUT"
|
|
||||||
# "release" gates the rest: a clean bump whose Release isn't published yet.
|
|
||||||
if [ "$KIND" != "none" ] && [ "$RELEASE_EXISTS" = "false" ]; then
|
if [ "$KIND" != "none" ] && [ "$RELEASE_EXISTS" = "false" ]; then
|
||||||
echo "release=true" >> "$GITHUB_OUTPUT"
|
echo "release=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "release=false" >> "$GITHUB_OUTPUT"
|
echo "release=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "v$TOP: kind=$KIND release_exists=$RELEASE_EXISTS — no action."
|
echo "v$TOP: kind=$KIND release_exists=$RELEASE_EXISTS — no action."
|
||||||
fi
|
fi
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
|
|
||||||
# Each artifact is created independently and idempotently so a re-run after
|
- name: Build release notes
|
||||||
# a mid-job failure can finish the release rather than skip it forever.
|
|
||||||
- name: Create tag
|
|
||||||
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.tag_exists == 'false' }}
|
|
||||||
run: |
|
|
||||||
V="v${{ steps.rel.outputs.version }}"
|
|
||||||
git tag -a "$V" -m "$V"
|
|
||||||
git push origin "$V"
|
|
||||||
|
|
||||||
- name: Build payload
|
|
||||||
if: ${{ steps.rel.outputs.release == 'true' }}
|
if: ${{ steps.rel.outputs.release == 'true' }}
|
||||||
run: |
|
run: |
|
||||||
python scripts/changelog_cli.py payload CHANGELOG.md "${{ steps.rel.outputs.version }}" > payload.json
|
python scripts/changelog_cli.py payload CHANGELOG.md "${{ steps.rel.outputs.version }}" > payload.json
|
||||||
python -c "import json; print(json.load(open('payload.json'))['notes'])" > notes.md
|
python -c "import json; print(json.load(open('payload.json'))['notes'])" > notes.md
|
||||||
|
|
||||||
|
- name: Upload notes artifact
|
||||||
|
if: ${{ steps.rel.outputs.release == 'true' }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-notes
|
||||||
|
path: |
|
||||||
|
payload.json
|
||||||
|
notes.md
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: prepare
|
||||||
|
if: ${{ needs.prepare.outputs.release == 'true' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write # create the tag + GitHub Release
|
||||||
|
id-token: write # OIDC to assume the notifier-invoke role
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.prepare.outputs.version }}
|
||||||
|
KIND: ${{ needs.prepare.outputs.kind }}
|
||||||
|
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-notes
|
||||||
|
|
||||||
# Announce BEFORE publishing the Release: the Release is the durable "done"
|
# Announce BEFORE publishing the Release: the Release is the durable "done"
|
||||||
# marker, so announcing first keeps the step retryable. Minor/major only,
|
# marker (prepare skips once it exists), so announcing first keeps this
|
||||||
# and only once the invoke-role variable has been bootstrapped (see README).
|
# retryable. Minor/major only, and only once the invoke-role variable has
|
||||||
|
# been bootstrapped (see README).
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
|
if: ${{ env.KIND != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
|
||||||
uses: aws-actions/configure-aws-credentials@v6
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
with:
|
with:
|
||||||
role-to-assume: ${{ vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN }}
|
role-to-assume: ${{ vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN }}
|
||||||
aws-region: us-east-1
|
aws-region: us-east-1
|
||||||
|
|
||||||
- name: Announce in Slack
|
- name: Announce in Slack
|
||||||
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
|
if: ${{ env.KIND != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
|
||||||
run: |
|
run: |
|
||||||
aws lambda invoke \
|
aws lambda invoke \
|
||||||
--function-name afterhours-release-notifier \
|
--function-name afterhours-release-notifier \
|
||||||
|
|
@ -108,18 +128,19 @@ jobs:
|
||||||
if grep -q '"FunctionError"' invoke-meta.json; then
|
if grep -q '"FunctionError"' invoke-meta.json; then
|
||||||
echo "::error::release-notifier returned an error"; cat response.json; exit 1
|
echo "::error::release-notifier returned an error"; cat response.json; exit 1
|
||||||
fi
|
fi
|
||||||
echo "Announced v${{ steps.rel.outputs.version }}."
|
echo "Announced v${VERSION}."
|
||||||
|
|
||||||
- name: Warn if announcement skipped (not bootstrapped)
|
- name: Warn if announcement skipped (not bootstrapped)
|
||||||
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN == '' }}
|
if: ${{ env.KIND != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN == '' }}
|
||||||
run: echo "::warning::RELEASE_NOTIFY_INVOKE_ROLE_ARN is unset — tagged + released but did not announce. Set the repo variable from the stack output."
|
run: echo "::warning::RELEASE_NOTIFY_INVOKE_ROLE_ARN is unset — tagging + releasing but not announcing. Set the repo variable from the stack output."
|
||||||
|
|
||||||
|
# No checkout: gh creates the tag at HEAD_SHA and the Release together.
|
||||||
- name: Publish GitHub Release
|
- name: Publish GitHub Release
|
||||||
if: ${{ steps.rel.outputs.release == 'true' }}
|
|
||||||
run: |
|
|
||||||
gh release create "v${{ steps.rel.outputs.version }}" \
|
|
||||||
--title "v${{ steps.rel.outputs.version }}" \
|
|
||||||
--notes-file notes.md \
|
|
||||||
--target "${{ github.event.workflow_run.head_sha }}"
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh release create "v${VERSION}" \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--title "v${VERSION}" \
|
||||||
|
--notes-file notes.md \
|
||||||
|
--target "${HEAD_SHA}"
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,15 @@ def markdown_to_mrkdwn(text: str) -> str:
|
||||||
``*italic*``, ``[text](url)``, ``- `` bullets), but Slack uses a different
|
``*italic*``, ``[text](url)``, ``- `` bullets), but Slack uses a different
|
||||||
dialect (``*bold*``, ``_italic_``, ``<url|text>``, ``•`` bullets). Bold is
|
dialect (``*bold*``, ``_italic_``, ``<url|text>``, ``•`` bullets). Bold is
|
||||||
swapped via a placeholder first so the italic pass cannot mangle it.
|
swapped via a placeholder first so the italic pass cannot mangle it.
|
||||||
|
|
||||||
|
The italic and link patterns use possessive quantifiers (``++``) and exclusive
|
||||||
|
character classes so they run in linear time on adversarial input — no
|
||||||
|
catastrophic backtracking (py/polynomial-redos).
|
||||||
"""
|
"""
|
||||||
text = re.sub(r"\*\*(.+?)\*\*", "\x00\\1\x00", text) # bold -> placeholder
|
text = re.sub(r"\*\*([^\n]+?)\*\*", "\x00\\1\x00", text) # bold -> placeholder
|
||||||
text = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"_\1_", text) # italic
|
text = re.sub(r"\*([^*\n]++)\*", r"_\1_", text) # italic (single asterisks)
|
||||||
text = text.replace("\x00", "*") # placeholder -> Slack bold
|
text = text.replace("\x00", "*") # placeholder -> Slack bold
|
||||||
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"<\2|\1>", text) # links
|
text = re.sub(r"\[([^\]]++)\]\(([^)\n]++)\)", r"<\2|\1>", text) # links
|
||||||
text = re.sub(r"(?m)^(\s*)[-*]\s+", r"\1• ", text) # bullets
|
text = re.sub(r"(?m)^(\s*)[-*]\s+", r"\1• ", text) # bullets
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,17 @@ class TestReleaseAnnouncement:
|
||||||
assert "• see <http://x|docs>" in out
|
assert "• see <http://x|docs>" in out
|
||||||
assert "• next" in out
|
assert "• next" in out
|
||||||
|
|
||||||
|
def test_adversarial_input_runs_in_linear_time(self):
|
||||||
|
# py/polynomial-redos regression: possessive quantifiers must keep these
|
||||||
|
# patterns from catastrophic backtracking. Pathological inputs that would
|
||||||
|
# hang a backtracking engine complete effectively instantly here.
|
||||||
|
import time
|
||||||
|
|
||||||
|
for evil in ("*" + "*a" * 4000, "[" + "[\\(" * 4000, "[" + "](" * 4000):
|
||||||
|
start = time.perf_counter()
|
||||||
|
markdown_to_mrkdwn(evil)
|
||||||
|
assert time.perf_counter() - start < 1.0
|
||||||
|
|
||||||
def test_blocks_have_header_and_notes(self):
|
def test_blocks_have_header_and_notes(self):
|
||||||
blocks = build_release_announcement_blocks(
|
blocks = build_release_announcement_blocks(
|
||||||
"1.10.0", "**Release notes** now self-announce.", "June 11, 2026"
|
"1.10.0", "**Release notes** now self-announce.", "June 11, 2026"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue