mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 07:53:11 +00:00
Move release/announce into Deploy workflow to clear CodeQL
The workflow_run-triggered release.yaml kept tripping CodeQL's privileged-context rules (untrusted-checkout, then cache-poisoning) — CodeQL distrusts any workflow_run that checks out a ref, regardless of the main-only guarantee, and there is no autofix. Fold the release job into deploy.yaml gated on `needs: deploy`. A push-to-main run is a trusted context, so checking out and running repo code with write/OIDC is safe there. This still gates on deploy success and serializes via the deploy concurrency group, and removes the separate workflow entirely.
This commit is contained in:
parent
7e4458724f
commit
ed32541331
3 changed files with 112 additions and 159 deletions
98
.github/workflows/deploy.yaml
vendored
98
.github/workflows/deploy.yaml
vendored
|
|
@ -20,3 +20,101 @@ jobs:
|
|||
secrets:
|
||||
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
||||
parameter-overrides: ${{ secrets.SAM_PARAMETER_OVERRIDES }}
|
||||
|
||||
# Tag + announce a release once the deploy succeeds. This lives in the deploy
|
||||
# workflow (gated on `needs: deploy`) rather than a separate workflow_run-
|
||||
# triggered job on purpose: a push-to-main run is a trusted context, so
|
||||
# checking out and running repo code with write/OIDC is safe here — unlike
|
||||
# workflow_run, which CodeQL (rightly) flags for untrusted checkout + cache
|
||||
# poisoning. Gating on `needs: deploy` still guarantees we never announce a
|
||||
# version that isn't live, and the `deploy` concurrency group serializes
|
||||
# releases. When the top CHANGELOG version already has a Release, this no-ops.
|
||||
release:
|
||||
needs: deploy
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # create the tag + GitHub Release
|
||||
id-token: write # OIDC to assume the notifier-invoke role
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Determine release
|
||||
id: rel
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TOP=$(python scripts/changelog_cli.py top-version CHANGELOG.md)
|
||||
if [ -z "$TOP" ]; then
|
||||
echo "No version entry in CHANGELOG.md — nothing to release."
|
||||
echo "release=false" >> "$GITHUB_OUTPUT"; exit 0
|
||||
fi
|
||||
PREV=$(git tag -l 'v*' --sort=-v:refname | head -1)
|
||||
PREV="${PREV:-v0.0.0}"
|
||||
KIND=$(python scripts/changelog_cli.py bump-kind CHANGELOG.md "$PREV")
|
||||
|
||||
RELEASE_EXISTS=false
|
||||
gh release view "v$TOP" >/dev/null 2>&1 && RELEASE_EXISTS=true
|
||||
|
||||
echo "version=$TOP" >> "$GITHUB_OUTPUT"
|
||||
echo "kind=$KIND" >> "$GITHUB_OUTPUT"
|
||||
# Act only on a clean SemVer bump whose Release isn't published yet.
|
||||
if [ "$KIND" != "none" ] && [ "$RELEASE_EXISTS" = "false" ]; then
|
||||
echo "release=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "release=false" >> "$GITHUB_OUTPUT"
|
||||
echo "v$TOP: kind=$KIND release_exists=$RELEASE_EXISTS — no action."
|
||||
fi
|
||||
|
||||
- name: Build release notes
|
||||
if: ${{ steps.rel.outputs.release == 'true' }}
|
||||
run: |
|
||||
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
|
||||
|
||||
# Announce BEFORE publishing the Release: the Release is the durable "done"
|
||||
# marker (the step above skips once it exists), so announcing first keeps
|
||||
# this retryable. Minor/major only, and only once the invoke-role variable
|
||||
# has been bootstrapped (see README).
|
||||
- name: Configure AWS credentials
|
||||
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: ${{ vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN }}
|
||||
aws-region: us-east-1
|
||||
|
||||
- name: Announce in Slack
|
||||
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
|
||||
run: |
|
||||
aws lambda invoke \
|
||||
--function-name afterhours-release-notifier \
|
||||
--cli-binary-format raw-in-base64-out \
|
||||
--payload file://payload.json \
|
||||
--output json response.json > invoke-meta.json
|
||||
# aws lambda invoke only emits a FunctionError key when the handler errored.
|
||||
if grep -q '"FunctionError"' invoke-meta.json; then
|
||||
echo "::error::release-notifier returned an error"; cat response.json; exit 1
|
||||
fi
|
||||
echo "Announced v${{ steps.rel.outputs.version }}."
|
||||
|
||||
- name: Warn if announcement skipped (not bootstrapped)
|
||||
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN == '' }}
|
||||
run: echo "::warning::RELEASE_NOTIFY_INVOKE_ROLE_ARN is unset — tagging + releasing but not announcing. Set the repo variable from the stack output."
|
||||
|
||||
- name: Publish GitHub Release
|
||||
if: ${{ steps.rel.outputs.release == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# gh creates the tag at the deployed commit and the Release together.
|
||||
gh release create "v${{ steps.rel.outputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "v${{ steps.rel.outputs.version }}" \
|
||||
--notes-file notes.md \
|
||||
--target "${{ github.sha }}"
|
||||
|
|
|
|||
146
.github/workflows/release.yaml
vendored
146
.github/workflows/release.yaml
vendored
|
|
@ -1,146 +0,0 @@
|
|||
name: Release
|
||||
|
||||
# Runs after a successful Deploy. When the top of CHANGELOG.md names a version
|
||||
# that has no Release yet, this tags it, publishes a GitHub Release with the
|
||||
# notes, and — for minor/major bumps only — invokes the release-notifier Lambda
|
||||
# to announce it in Slack. Triggering on Deploy completion (not release:published
|
||||
# / 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
|
||||
# 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:
|
||||
workflow_run:
|
||||
workflows: ["Deploy"]
|
||||
types: [completed]
|
||||
|
||||
# Serialize so back-to-back releases announce in order, never overlapping.
|
||||
concurrency:
|
||||
group: release-announce
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
# 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
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
release: ${{ steps.rel.outputs.release }}
|
||||
version: ${{ steps.rel.outputs.version }}
|
||||
kind: ${{ steps.rel.outputs.kind }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Determine release
|
||||
id: rel
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TOP=$(python scripts/changelog_cli.py top-version CHANGELOG.md)
|
||||
if [ -z "$TOP" ]; then
|
||||
echo "No version entry in CHANGELOG.md — nothing to release."
|
||||
echo "release=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
PREV=$(git tag -l 'v*' --sort=-v:refname | head -1)
|
||||
PREV="${PREV:-v0.0.0}"
|
||||
KIND=$(python scripts/changelog_cli.py bump-kind CHANGELOG.md "$PREV")
|
||||
|
||||
RELEASE_EXISTS=false
|
||||
gh release view "v$TOP" >/dev/null 2>&1 && RELEASE_EXISTS=true
|
||||
|
||||
echo "version=$TOP" >> "$GITHUB_OUTPUT"
|
||||
echo "kind=$KIND" >> "$GITHUB_OUTPUT"
|
||||
# Gate the publish job on a clean bump whose Release isn't published yet.
|
||||
if [ "$KIND" != "none" ] && [ "$RELEASE_EXISTS" = "false" ]; then
|
||||
echo "release=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "release=false" >> "$GITHUB_OUTPUT"
|
||||
echo "v$TOP: kind=$KIND release_exists=$RELEASE_EXISTS — no action."
|
||||
fi
|
||||
|
||||
- name: Build release notes
|
||||
if: ${{ steps.rel.outputs.release == 'true' }}
|
||||
run: |
|
||||
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
|
||||
|
||||
- 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"
|
||||
# marker (prepare skips once it exists), so announcing first keeps this
|
||||
# retryable. Minor/major only, and only once the invoke-role variable has
|
||||
# been bootstrapped (see README).
|
||||
- name: Configure AWS credentials
|
||||
if: ${{ env.KIND != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: ${{ vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN }}
|
||||
aws-region: us-east-1
|
||||
|
||||
- name: Announce in Slack
|
||||
if: ${{ env.KIND != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
|
||||
run: |
|
||||
aws lambda invoke \
|
||||
--function-name afterhours-release-notifier \
|
||||
--cli-binary-format raw-in-base64-out \
|
||||
--payload file://payload.json \
|
||||
--output json response.json > invoke-meta.json
|
||||
# aws lambda invoke only emits a FunctionError key when the handler errored.
|
||||
if grep -q '"FunctionError"' invoke-meta.json; then
|
||||
echo "::error::release-notifier returned an error"; cat response.json; exit 1
|
||||
fi
|
||||
echo "Announced v${VERSION}."
|
||||
|
||||
- name: Warn if announcement skipped (not bootstrapped)
|
||||
if: ${{ env.KIND != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN == '' }}
|
||||
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
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create "v${VERSION}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "v${VERSION}" \
|
||||
--notes-file notes.md \
|
||||
--target "${HEAD_SHA}"
|
||||
27
README.md
27
README.md
|
|
@ -64,7 +64,7 @@ Dates accept: `today`, `tomorrow`, `monday`-`sunday`, `4/5`, `2026-04-05`
|
|||
| `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email |
|
||||
| `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX |
|
||||
| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift |
|
||||
| `afterhours-release-notifier` | Invoked by `release.yaml` on minor/major releases | Posts a "What's New" announcement to the shift channel |
|
||||
| `afterhours-release-notifier` | Invoked by the Deploy workflow's release job on minor/major releases | Posts a "What's New" announcement to the shift channel |
|
||||
|
||||
### Project Layout
|
||||
|
||||
|
|
@ -131,18 +131,19 @@ bumping per SemVer, then run `python scripts/sync_changelog.py` to update the
|
|||
in-package copy. The `Changelog Guard` PR check enforces that the bump is a clean
|
||||
single SemVer step above the latest tag and that the two copies match.
|
||||
|
||||
On the **deploy-then-merge** path, once the merge's Deploy succeeds,
|
||||
`.github/workflows/release.yaml` (triggered on Deploy completion) tags the new
|
||||
version, publishes a GitHub Release with the notes, and — for **minor and major**
|
||||
bumps only (patches stay silent) — invokes `afterhours-release-notifier` to post a
|
||||
"What's New" message in the shift channel. The **App Home** tab ("About" page on
|
||||
the bot) always shows the current version's notes, read from the CHANGELOG that
|
||||
ships in the slack-bot package.
|
||||
On the **deploy-then-merge** path, once the merge's Deploy succeeds, the Deploy
|
||||
workflow's `release` job (`needs: deploy`) tags the new version, publishes a
|
||||
GitHub Release with the notes, and — for **minor and major** bumps only (patches
|
||||
stay silent) — invokes `afterhours-release-notifier` to post a "What's New"
|
||||
message in the shift channel. The **App Home** tab ("About" page on the bot)
|
||||
always shows the current version's notes, read from the CHANGELOG that ships in
|
||||
the slack-bot package.
|
||||
|
||||
> Triggering on Deploy success (rather than `release: published` or a tag push) is
|
||||
> deliberate: GitHub does not start downstream workflows from `GITHUB_TOKEN`-created
|
||||
> events, and gating on a successful deploy guarantees we never announce a version
|
||||
> that isn't actually live.
|
||||
> The release job lives inside the Deploy workflow (gated on `needs: deploy`)
|
||||
> rather than a separate `workflow_run`-triggered workflow. A push-to-main run is
|
||||
> a trusted context, so checking out and running repo code with write/OIDC is safe
|
||||
> — whereas `workflow_run` is flagged by CodeQL for untrusted checkout. Gating on
|
||||
> `needs: deploy` still guarantees we never announce a version that isn't live.
|
||||
|
||||
**One-time setup (per environment):** after the first deploy creates the
|
||||
`ReleaseNotifyInvokeRole`, copy its ARN from the `ReleaseNotifyInvokeRoleArn` stack
|
||||
|
|
@ -153,7 +154,7 @@ and publish but skip the Slack announcement (with a warning).
|
|||
> **Convention note (deliberate deviation).** The Sea Haven handbook says internal
|
||||
> SAM stacks generally need no versioning and that tags are applied manually. This
|
||||
> bot is versioned by owner choice (it has staff-facing release notes) and tagged
|
||||
> automatically by `release.yaml`. This is intentional — not drift.
|
||||
> automatically by the Deploy workflow's release job. This is intentional — not drift.
|
||||
|
||||
## Testing
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue