|
| 1 | +name: Activate release freeze |
| 2 | +description: Freeze master and grant the release bot temporary direct-push bypasses. |
| 3 | +inputs: |
| 4 | + app-client-id: |
| 5 | + description: Client ID of the vectordotdev-bot GitHub App, which requires Administration write permission. |
| 6 | + required: true |
| 7 | + app-private-key: |
| 8 | + description: Private key of the vectordotdev-bot GitHub App. |
| 9 | + required: true |
| 10 | + freeze-ruleset-id: |
| 11 | + description: Repository variable RELEASE_FREEZE_RULESET_ID (the vars context is unavailable in composite actions). |
| 12 | + required: true |
| 13 | + bot-bypass-ruleset-ids: |
| 14 | + description: Repository variable RELEASE_FREEZE_BOT_BYPASS, comma-separated ruleset IDs (the vars context is unavailable in composite actions). |
| 15 | + required: true |
| 16 | +runs: |
| 17 | + using: composite |
| 18 | + steps: |
| 19 | + # Keep ruleset administration separate from tokens used to push release commits. |
| 20 | + - name: Create Vector bot token for ruleset updates |
| 21 | + id: bot-token |
| 22 | + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 |
| 23 | + with: |
| 24 | + client-id: ${{ inputs.app-client-id }} |
| 25 | + private-key: ${{ inputs.app-private-key }} |
| 26 | + permission-administration: write |
| 27 | + permission-contents: read |
| 28 | + |
| 29 | + - name: Activate freeze and configure bot bypasses |
| 30 | + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
| 31 | + env: |
| 32 | + BOT_APP_SLUG: ${{ steps.bot-token.outputs.app-slug }} |
| 33 | + RELEASE_FREEZE_RULESET_ID: ${{ inputs.freeze-ruleset-id }} |
| 34 | + RELEASE_FREEZE_BOT_BYPASS: ${{ inputs.bot-bypass-ruleset-ids }} |
| 35 | + with: |
| 36 | + github-token: ${{ steps.bot-token.outputs.token }} |
| 37 | + script: | |
| 38 | + if (process.env.BOT_APP_SLUG !== "vectordotdev-bot") { |
| 39 | + throw new Error("Release freeze activation requires the vectordotdev-bot GitHub App."); |
| 40 | + } |
| 41 | + const { readRulesetIds, isBotActor, hasBotBypass, restrictsMasterUpdates, canManageReleaseBypass } = |
| 42 | + require("./.github/actions/require-release-freeze/rulesets.js"); |
| 43 | + const { freezeId, bypassIds } = readRulesetIds(process.env); |
| 44 | + const { data: app } = await github.request("GET /apps/{app_slug}", { |
| 45 | + app_slug: process.env.BOT_APP_SLUG, |
| 46 | + }); |
| 47 | + const { data: repository } = await github.request("GET /repos/{owner}/{repo}", context.repo); |
| 48 | + const branchRules = () => github.paginate( |
| 49 | + "GET /repos/{owner}/{repo}/rules/branches/{branch}", |
| 50 | + { ...context.repo, branch: "master" }, |
| 51 | + ); |
| 52 | + const rules = await branchRules(); |
| 53 | + // Include configured IDs even when disabled or not effective on master, |
| 54 | + // so invalid configuration fails instead of silently skipping a ruleset. |
| 55 | + const ids = new Set([freezeId, ...bypassIds, ...rules.map((rule) => rule.ruleset_id)]); |
| 56 | + const details = await Promise.all([...ids].map(async (id) => { |
| 57 | + const { data } = await github.request( |
| 58 | + "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}", |
| 59 | + { ...context.repo, ruleset_id: id, includes_parents: true }, |
| 60 | + ); |
| 61 | + return data; |
| 62 | + })); |
| 63 | + const byId = new Map(details.map((ruleset) => [ruleset.id, ruleset])); |
| 64 | + const freeze = byId.get(freezeId); |
| 65 | + if (!["active", "disabled"].includes(freeze.enforcement) || |
| 66 | + !restrictsMasterUpdates(freeze, repository.default_branch) || !hasBotBypass(freeze, app.id)) { |
| 67 | + throw new Error(`Ruleset ${freezeId} must restrict master updates and already permit direct bot pushes.`); |
| 68 | + } |
| 69 | + for (const type of ["non_fast_forward", "deletion"]) { |
| 70 | + if (!rules.some((rule) => rule.type === type && byId.get(rule.ruleset_id)?.bypass_actors?.length === 0)) { |
| 71 | + throw new Error(`master must retain an active ${type} rule without bypass actors.`); |
| 72 | + } |
| 73 | + } |
| 74 | +
|
| 75 | + const pushSafe = new Set(["creation", "deletion", "non_fast_forward", "required_linear_history"]); |
| 76 | + const updates = []; |
| 77 | + for (const id of bypassIds) { |
| 78 | + const ruleset = byId.get(id); |
| 79 | + // A ruleset bypass applies to every rule and branch in that ruleset. |
| 80 | + // Do not broaden it to other branches, inherited policy, or safety rules. |
| 81 | + if (!canManageReleaseBypass(ruleset, repository.default_branch) || !rules.some((rule) => rule.ruleset_id === id)) { |
| 82 | + throw new Error(`Cannot safely grant a release bypass to configured ruleset ${id} (${ruleset.name}). It must be active and contain only master release policy.`); |
| 83 | + } |
| 84 | + if (!Array.isArray(ruleset.bypass_actors)) { |
| 85 | + throw new Error(`Cannot read bypass actors for ruleset ${id} (${ruleset.name}).`); |
| 86 | + } |
| 87 | + if (!hasBotBypass(ruleset, app.id)) { |
| 88 | + updates.push(ruleset); |
| 89 | + } |
| 90 | + } |
| 91 | + for (const rule of rules) { |
| 92 | + if (!pushSafe.has(rule.type) && rule.ruleset_id !== freezeId && |
| 93 | + !bypassIds.includes(rule.ruleset_id) && !hasBotBypass(byId.get(rule.ruleset_id), app.id)) { |
| 94 | + throw new Error(`Ruleset ${rule.ruleset_id} blocks direct pushes but is missing from RELEASE_FREEZE_BOT_BYPASS.`); |
| 95 | + } |
| 96 | + } |
| 97 | +
|
| 98 | + // Freeze first. If a later update fails, leave master frozen; a retry |
| 99 | + // completes the remaining bypasses without duplicating existing actors. |
| 100 | + // Only toggle enforcement: Release freeze keeps its existing bypass actors. |
| 101 | + if (freeze.enforcement !== "active") { |
| 102 | + await github.request("PUT /repos/{owner}/{repo}/rulesets/{ruleset_id}", { |
| 103 | + ...context.repo, |
| 104 | + ruleset_id: freeze.id, |
| 105 | + enforcement: "active", |
| 106 | + }); |
| 107 | + } |
| 108 | + if (!(await branchRules()).some((rule) => rule.ruleset_id === freeze.id && rule.type === "update")) { |
| 109 | + throw new Error("Release freeze must restrict updates to master before granting bot bypasses."); |
| 110 | + } |
| 111 | + for (const ruleset of updates) { |
| 112 | + // Update only the bot's bypass, never the freeze or safety policies. |
| 113 | + await github.request("PUT /repos/{owner}/{repo}/rulesets/{ruleset_id}", { |
| 114 | + ...context.repo, |
| 115 | + ruleset_id: ruleset.id, |
| 116 | + bypass_actors: [ |
| 117 | + ...ruleset.bypass_actors.filter((actor) => !isBotActor(actor, app.id)), |
| 118 | + { actor_id: app.id, actor_type: "Integration", bypass_mode: "always" }, |
| 119 | + ], |
| 120 | + }); |
| 121 | + core.info(`Granted release bypass on ruleset ${ruleset.id} (${ruleset.name}).`); |
| 122 | + } |
| 123 | +
|
| 124 | + # Check effective permissions with a contents-only token, not the privileged |
| 125 | + # administration token used above. This also checks inherited rulesets. |
| 126 | + - name: Verify active release freeze |
| 127 | + uses: ./.github/actions/require-release-freeze |
| 128 | + with: |
| 129 | + app-client-id: ${{ inputs.app-client-id }} |
| 130 | + app-private-key: ${{ inputs.app-private-key }} |
| 131 | + freeze-ruleset-id: ${{ inputs.freeze-ruleset-id }} |
| 132 | + bot-bypass-ruleset-ids: ${{ inputs.bot-bypass-ruleset-ids }} |
0 commit comments