Fix: require('@actions/github') not resolvable from external main.js

PR #5 broke the action in production — the require() fails with
MODULE_NOT_FOUND because actions/github-script bundles @actions/github
into dist/index.js; it's not in node_modules and not resolvable from
a sibling require()'d file.

Fix: flip the architecture. github-script's 'github' client now uses
bot-token (GITHUB_TOKEN), so the single 'github' object serves all
user-visible operations. The PAT is passed via env (ORG_TOKEN) and
used ONLY for the team-membership call via github.request() with an
explicit authorization header override.

Side effect: PAT requirements shrink further. It now ONLY needs
Organization Members:read — no repo perms, no Actions:write. All
repo-level operations (including workflow dispatch) use GITHUB_TOKEN
with the workflow's permissions: block.
This commit is contained in:
Den Delimarsky 2026-03-11 20:52:21 +00:00
commit 9fc2acf515
No known key found for this signature in database
2 changed files with 32 additions and 31 deletions

View file

@ -9,19 +9,19 @@ description: >-
inputs:
github-token:
description: >-
Token for org team-membership checks and workflow dispatch. MUST have
read:org scope — the default GITHUB_TOKEN cannot do this. Use a
fine-grained PAT with Organization Members:read (+ Actions:write if
using /stageblog), or a GitHub App installation token with equivalent
perms.
Token used for org team-membership checks only. MUST have read:org
scope — the default GITHUB_TOKEN cannot do this. Use a fine-grained PAT
with Organization Members:read, or a GitHub App installation token with
the same.
required: true
bot-token:
description: >-
Token used for user-visible side effects (comments, reactions, labels,
reviews). Defaults to the automatic GITHUB_TOKEN so these show as
authored by github-actions[bot]. The caller workflow must grant this
token the necessary repo permissions (pull-requests: write,
issues: write).
Token used for all repo API calls: comments, reactions, labels, reviews,
CODEOWNERS fetch, listFiles, and workflow dispatch. Defaults to the
automatic GITHUB_TOKEN so user-visible side effects show as authored by
github-actions[bot]. The caller workflow must grant this token the
necessary permissions (pull-requests: write, issues: write,
contents: read; plus actions: write for /stageblog).
required: false
default: ${{ github.token }}
approved-label:
@ -95,9 +95,9 @@ runs:
SUBMIT_REVIEW: ${{ inputs.submit-review }}
STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }}
STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }}
BOT_TOKEN: ${{ inputs.bot-token }}
ORG_TOKEN: ${{ inputs.github-token }}
with:
github-token: ${{ inputs.github-token }}
github-token: ${{ inputs.bot-token }}
script: |
const main = require(process.env.GITHUB_ACTION_PATH + '/main.js');
await main({ github, context, core });

View file

@ -12,13 +12,10 @@ module.exports = async ({ github, context, core }) => {
const invalidateOnPush = process.env.INVALIDATE_ON_PUSH === 'true';
const submitReview = process.env.SUBMIT_REVIEW === 'true';
const reviewMarker = '<!-- slash-commands-lgtm -->';
// Separate client for user-visible side effects (comments, reactions,
// labels, reviews). Uses github.token so everything shows as authored by
// github-actions[bot] instead of the PAT's identity. The PAT (`github`)
// is still used for team-membership checks (needs read:org), CODEOWNERS
// fetch, listFiles, pulls.get, and workflow dispatch.
const bot = require('@actions/github').getOctokit(process.env.BOT_TOKEN);
// `github` is authed with bot-token (defaults to GITHUB_TOKEN) so comments,
// reactions, labels, and reviews show as github-actions[bot]. The PAT is
// passed separately and used ONLY for team-membership checks (read:org).
const orgToken = process.env.ORG_TOKEN;
function setResult(result, actor) {
core.setOutput('result', result);
@ -83,9 +80,13 @@ module.exports = async ({ github, context, core }) => {
const key = `${teamSlug}:${user}`;
if (teamMembershipCache.has(key)) return teamMembershipCache.get(key);
try {
const { data } = await github.rest.teams.getMembershipForUserInOrg({
org: owner, team_slug: teamSlug, username: user,
});
const { data } = await github.request(
'GET /orgs/{org}/teams/{team_slug}/memberships/{username}',
{
org: owner, team_slug: teamSlug, username: user,
headers: { authorization: `token ${orgToken}` },
},
);
const ok = data.state === 'active';
teamMembershipCache.set(key, ok);
return ok;
@ -157,13 +158,13 @@ module.exports = async ({ github, context, core }) => {
// --- Label & review helpers -------------------------------------
async function addLabel(prNumber, label) {
await bot.rest.issues.addLabels({
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [label],
});
}
async function removeLabel(prNumber, label) {
try {
await bot.rest.issues.removeLabel({
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label,
});
} catch (e) {
@ -171,25 +172,25 @@ module.exports = async ({ github, context, core }) => {
}
}
async function react(commentId, content) {
await bot.rest.reactions.createForIssueComment({
await github.rest.reactions.createForIssueComment({
owner, repo, comment_id: commentId, content,
});
}
async function submitApproval(prNumber, onBehalfOf) {
await bot.rest.pulls.createReview({
await github.rest.pulls.createReview({
owner, repo, pull_number: prNumber, event: 'APPROVE',
body: `${reviewMarker}\nApproved on behalf of @${onBehalfOf} via \`/lgtm\`.`,
});
}
async function dismissBotApprovals(prNumber, reason) {
const reviews = await bot.paginate(bot.rest.pulls.listReviews, {
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner, repo, pull_number: prNumber, per_page: 100,
});
for (const r of reviews) {
if (r.state !== 'APPROVED') continue;
if (!r.body || !r.body.includes(reviewMarker)) continue;
try {
await bot.rest.pulls.dismissReview({
await github.rest.pulls.dismissReview({
owner, repo, pull_number: prNumber, review_id: r.id,
message: reason,
});
@ -210,7 +211,7 @@ module.exports = async ({ github, context, core }) => {
if (!hasApproved) return setResult('noop');
await removeLabel(pr.number, approvedLabel);
await dismissBotApprovals(pr.number, 'New commits pushed — approval invalidated.');
await bot.rest.issues.createComment({
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number,
body: `New commits were pushed — removed the \`${approvedLabel}\` label. Re-approve with \`/lgtm\`.`,
});
@ -270,7 +271,7 @@ module.exports = async ({ github, context, core }) => {
const touchesBlog = files.some(f => blogRegexes.some(r => r.test(f.filename)));
if (!touchesBlog) {
await react(commentId, '-1');
await bot.rest.issues.createComment({
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: `\`/stageblog\` refused — this PR does not touch any blog paths (\`${process.env.STAGEBLOG_PATHS}\`).`,
});
@ -284,7 +285,7 @@ module.exports = async ({ github, context, core }) => {
inputs: { pr_number: String(prNumber), head_sha: headSha },
});
await react(commentId, 'rocket');
await bot.rest.issues.createComment({
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: `Blog staging triggered by @${actor} for \`${headSha.slice(0, 7)}\`. Watch the [**${stageblogWorkflow}** workflow](../actions/workflows/${stageblogWorkflow}) for the preview link.`,
});