Skip to content
Open
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
10 changes: 7 additions & 3 deletions .github/workflows/build-previews.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,13 @@ jobs:
packages: read
pull-requests: write

# Preview deploy needs the MITTWALD_* secrets, which GitHub withholds from
# Dependabot-triggered runs and from forks. Skip there so those PRs don't
# fail on missing secrets (the build job above still validates the images).
# Preview deploy needs the MITTWALD_* secrets, which a Dependabot-triggered
# run resolves against the Dependabot secret store (empty there) and a fork
# does not get at all. Skip both so those PRs don't fail on missing secrets
# (the build job above still validates the images).
# A Dependabot PR is deployed all the same — by
# `deploy-previews-dependabot.yml`, which this run triggers via
# `workflow_run` and which does get the secrets. A fork's is not.
if: >
github.event_name == 'pull_request' &&
github.actor != 'dependabot[bot]' &&
Expand Down
107 changes: 107 additions & 0 deletions .github/workflows/cleanup-previews-dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Tears down the preview apps of a Dependabot PR.
#
# The counterpart to `deploy-previews-dependabot.yml`, and it exists for the
# same reason: Dependabot merges its own PRs, so the `closed` event is a
# Dependabot-triggered run, where `secrets.*` comes from the Dependabot store
# and the `MITTWALD_*` credentials are empty. That is not hypothetical — it is
# what every Dependabot merge did until this pair existed, failing on "Missing
# required environment variables" after the merge, where nobody looks.
# `cleanup-previews.yml` now skips its steps for that actor and completes green
# — this workflow is what its completion triggers, from the default branch, with
# the secrets.
#
# Unlike the deploy side there is no conclusion to filter on: the cleanup run
# reaches here having done nothing at all, which is the point.
name: Cleanup Preview Apps (Dependabot)

on:
workflow_run:
workflows: ["Cleanup Preview Apps"]
types:
- completed

# See the deploy workflow: the two are keyed the same so that queuing this
# cleanup cancels a deploy still in flight for the branch.
concurrency:
group: preview-dependabot-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true

jobs:
cleanup:
runs-on: ubuntu-latest
timeout-minutes: 10

# The actor test is the exact complement of `CLEAN_UP_HERE` in
# `cleanup-previews.yml`: a PR a person closes is cleaned up there, and only
# what Dependabot closed itself is left for this run.
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.actor.login == 'dependabot[bot]' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
startsWith(github.event.workflow_run.head_branch, 'dependabot/')

permissions:
contents: read
pull-requests: read # resolving the PR number for the branch

steps:
- name: Resolve the pull request
id: pr
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ github.token }}
script: |
const run = context.payload.workflow_run;
const { owner, repo } = context.repo;

// `workflow_run.pull_requests` covers open PRs; by the time a
// cleanup is triggered the PR is closed, so the lookup is the normal
// path here rather than the fallback it is on the deploy side.
let number = run.pull_requests?.[0]?.number;
if (!number) {
const { data } = await github.rest.pulls.list({
owner,
repo,
state: "closed",
head: `${owner}:${run.head_branch}`,
sort: "created",
direction: "desc",
});
number = data[0]?.number;
}
if (!number) {
const reason = "no closed PR found for this branch";
core.info(`nothing to clean up: ${reason}`);
core.summary.addRaw(`Nothing to clean up for \`${run.head_branch}\`: ${reason}.`).write();
return;
}

core.setOutput("number", String(number));

- name: Checkout the default branch
if: steps.pr.outputs.number != ''
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v6

- name: Install pnpm
if: steps.pr.outputs.number != ''
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

- name: Setup Node.js
if: steps.pr.outputs.number != ''
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: "24"
cache: pnpm

- name: Install dependencies
if: steps.pr.outputs.number != ''
run: pnpm install --frozen-lockfile

- name: Cleanup preview environment
if: steps.pr.outputs.number != ''
env:
PR_NUMBER: ${{ steps.pr.outputs.number }}
MITTWALD_PROJECT_ID: ${{ secrets.MITTWALD_PROJECT_ID }}
MITTWALD_API_TOKEN: ${{ secrets.MITTWALD_API_TOKEN }}
run: |
pnpm tsx dev/cleanup-review.ts
16 changes: 16 additions & 0 deletions .github/workflows/cleanup-previews.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,39 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10

env:
# Dependabot merges its own PRs, so the `closed` event is a
# Dependabot-triggered run — where `secrets.*` resolves against the
# Dependabot store and the MITTWALD_* credentials arrive empty. The
# cleanup for them happens in
# `cleanup-previews-dependabot.yml`, which this run triggers via
# `workflow_run`. The steps are skipped rather than the job: a job-level
# `if` concludes the whole run `skipped`, and only a run that actually
# completes is a dependable trigger.
CLEAN_UP_HERE: ${{ github.actor != 'dependabot[bot]' }}

steps:
- name: Checkout repository
if: env.CLEAN_UP_HERE == 'true'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v6

- name: Install pnpm
if: env.CLEAN_UP_HERE == 'true'
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

- name: Setup Node.js
if: env.CLEAN_UP_HERE == 'true'
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: "24"
cache: pnpm

- name: Install dependencies
if: env.CLEAN_UP_HERE == 'true'
run: pnpm install --frozen-lockfile

- name: Cleanup preview environment
if: env.CLEAN_UP_HERE == 'true'
env:
PR_NUMBER:
${{ github.event.pull_request.number || github.event.inputs.pr_number
Expand Down
61 changes: 54 additions & 7 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Auto-merges Dependabot PRs once the visual regression suite is green.
# Auto-merges Dependabot PRs once the visual regression and cross-version suites
# are green.
#
# Why this hangs off `workflow_run` instead of `pull_request`:
#
# - `pull_request` on a Dependabot PR gets a READ-ONLY `GITHUB_TOKEN`, so it
# cannot comment.
# - `pull_request` fires when the PR changes, not when a suite finishes, so it
# never sees the verdict it is supposed to act on.
# - `pull_request_target` would give a write token to a run that checks out
# dependency code. This workflow never checks anything out.
# - `workflow_run` runs from the default branch with a write token and only
Expand All @@ -20,17 +21,22 @@ name: Dependabot auto-merge

on:
workflow_run:
workflows: ["Run Visual Regression Tests"]
# Both suites run unconditionally on a Dependabot PR, and either completing
# lands here — the one that arrives second is the run that finds them both
# green.
workflows: ["Run Visual Regression Tests", "Run Cross-Version Tests"]
types:
- completed

permissions: {}

jobs:
auto-merge:
# Visual is the gate this workflow owns. The required `main` check is
# enforced by Dependabot when it processes the merge command, and by the
# ruleset, which does NOT grant Dependabot a bypass on status checks.
# Visual and cross-version are the gates this workflow owns: neither is a
# required status check, so nothing else stops a merge over a red one. The
# required `main` check is enforced by Dependabot when it processes the merge
# command, and by the ruleset, which does NOT grant Dependabot a bypass on
# status checks.
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request' &&
Expand All @@ -43,6 +49,7 @@ jobs:
cancel-in-progress: false

permissions:
actions: read # reads the gate suites' runs for the PR head
contents: read
pull-requests: write # comments the merge command

Expand Down Expand Up @@ -124,6 +131,46 @@ jobs:
return decline("the PR carries a major version update");
}

// The completion that triggered this run says nothing about the
// other suite, so check both against the PR head — including the one
// that just finished, whose verdict is read here rather than trusted
// from the payload.
const gates = ["Run Visual Regression Tests", "Run Cross-Version Tests"];
const runsForHead = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
owner,
repo,
head_sha: pr.head.sha,
});

for (const gate of gates) {
// One commit carries several runs of the same workflow: Dependabot
// applies its labels right after opening the PR, and every
// `labeled` event starts one that the concurrency group then
// cancels. Four of them, `created_at` equal to the second — so
// order by `id`, which is monotonic, and drop the superseded ones
// rather than let a cancelled sibling outvote the run that did
// the work. What is left is the newest run that means something:
// still going (wait for its own completion to land here),
// successful, or failed.
const [latest] = runsForHead
.filter((candidate) => candidate.name === gate)
.filter(
(candidate) =>
candidate.conclusion !== "cancelled" && candidate.conclusion !== "skipped",
)
.sort((a, b) => b.id - a.id);

if (!latest) {
return decline(`${gate} has not run for ${pr.head.sha.slice(0, 7)}`);
}
if (latest.status !== "completed") {
return decline(`${gate} is still ${latest.status}`);
}
if (latest.conclusion !== "success") {
return decline(`${gate} concluded ${latest.conclusion}`);
}
}

// Every rebase produces a fresh run, so only skip a command that was
// already issued for THIS head commit.
const command = "@dependabot squash and merge";
Expand Down
Loading
Loading