From ed3254133191c3e273a2e222ebacf8fd5a873792 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 11 Jun 2026 19:38:53 -0400 Subject: [PATCH] Move release/announce into Deploy workflow to clear CodeQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/deploy.yaml | 98 ++++++++++++++++++++++ .github/workflows/release.yaml | 146 --------------------------------- README.md | 27 +++--- 3 files changed, 112 insertions(+), 159 deletions(-) delete mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 96c2c05..4248aaf 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -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 }}" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index 1125056..0000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -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}" diff --git a/README.md b/README.md index 2872035..5bbf386 100644 --- a/README.md +++ b/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