Skip to content

Publish rebuilt action bundle #1

Publish rebuilt action bundle

Publish rebuilt action bundle #1

name: Publish rebuilt action bundle
# workflow_run loads this publisher from the default branch. Never check out
# PR code, restore its caches, or execute anything from the downloaded artifact.
on:
workflow_run:
workflows: [Rebuild action bundle]
types: [completed]
permissions: {}
jobs:
publish:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: read
contents: read
pull-requests: read
steps:
- name: Validate the build request
id: request
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const run = context.payload.workflow_run;
const repository = `${context.repo.owner}/${context.repo.repo}`;
const path = '.github/workflows/rebuild-bundle.yml';
if (run.event !== 'pull_request' || run.conclusion !== 'success' ||
run.path !== path || run.head_repository?.full_name !== repository) {
core.info('This run is not a successful same-repository PR rebuild.');
return;
}
const match = /^Rebuild PR #([1-9][0-9]*) at ([0-9a-f]{40})$/.exec(run.display_title);
if (!match || match[2] !== run.head_sha) {
throw new Error('The build title must identify the PR and the run head SHA.');
}
// Reject altered triggers, checkout refs, and run-name expressions.
const { data: trusted } = await github.rest.repos.getContent({
...context.repo, path, ref: context.sha,
});
const { data: requested } = await github.rest.repos.getContent({
...context.repo, path, ref: run.head_sha,
});
if (trusted.type !== 'file' || requested.type !== 'file' || trusted.sha !== requested.sha) {
throw new Error('Update the PR branch: rebuild-bundle.yml must match the default branch.');
}
const { data: pr } = await github.rest.pulls.get({
...context.repo, pull_number: Number(match[1]),
});
if (pr.state !== 'open' || pr.head.sha !== run.head_sha ||
pr.head.repo?.full_name !== repository || pr.base.repo.full_name !== repository ||
pr.head.ref !== run.head_branch || pr.head.ref === pr.base.ref ||
pr.head.ref === context.payload.repository.default_branch ||
!pr.labels.some(label => label.name === 'needs-bundle-rebuild')) {
core.info('The PR changed or is no longer eligible for a bundle rebuild.');
return;
}
core.setOutput('pr-number', pr.number);
core.setOutput('branch', pr.head.ref);
core.setOutput('ready', 'true');
- name: Download bundles from this build attempt
if: steps.request.outputs.ready == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rebuilt-bundles-${{ github.event.workflow_run.run_attempt }}
path: ${{ runner.temp }}/rebuilt-bundles
github-token: ${{ github.token }}
run-id: ${{ github.event.workflow_run.id }}
- name: Validate bundles and prepare commit
id: prepare
if: steps.request.outputs.ready == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ steps.request.outputs.pr-number }}
PR_BRANCH: ${{ steps.request.outputs.branch }}
with:
script: |
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const expected = {
number: Number(process.env.PR_NUMBER),
head: { sha: context.payload.workflow_run.head_sha, ref: process.env.PR_BRANCH },
};
const repository = `${context.repo.owner}/${context.repo.repo}`;
const { data: pr } = await github.rest.pulls.get({
...context.repo, pull_number: expected.number,
});
if (pr.state !== 'open' || pr.head.sha !== expected.head.sha ||
pr.head.ref !== expected.head.ref || pr.head.repo?.full_name !== repository ||
!pr.labels.some(label => label.name === 'needs-bundle-rebuild')) {
core.info('The PR changed or no longer qualifies for a bundle rebuild; skip this result.');
return;
}
const files = ['index.mjs', 'gitlab/index.mjs', 'azure/index.mjs'];
const directories = ['', 'gitlab', 'azure'];
const root = path.join(process.env.RUNNER_TEMP, 'rebuilt-bundles');
// Reject unexpected paths, symlinks, and non-regular files before reading data.
for (const directory of directories) {
const absolute = path.join(root, directory);
if (!fs.lstatSync(absolute).isDirectory()) {
throw new Error(`Expected a bundle directory: ${directory}`);
}
for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) {
const relative = directory ? `${directory}/${entry.name}` : entry.name;
if (directories.includes(relative) && entry.isDirectory()) continue;
if (files.includes(relative) && entry.isFile()) continue;
throw new Error(`Unexpected bundle artifact entry: ${relative}`);
}
}
const { data: commit } = await github.rest.git.getCommit({
...context.repo, commit_sha: expected.head.sha,
});
const { data: tree } = await github.rest.git.getTree({
...context.repo, tree_sha: commit.tree.sha, recursive: 'true',
});
if (tree.truncated) throw new Error('Cannot verify a truncated repository tree.');
const additions = [];
for (const file of files) {
const absolute = path.join(root, file);
const stat = fs.lstatSync(absolute);
if (!stat.isFile() || stat.size === 0 || stat.size > 10 * 1024 * 1024) {
throw new Error(`Invalid bundle file: ${file}`);
}
const content = fs.readFileSync(absolute);
const destination = `dist/${file}`;
const previous = tree.tree.find(entry => entry.path === destination);
if (previous && (previous.type !== 'blob' || previous.mode !== '100644')) {
throw new Error(`Expected a regular repository file: ${destination}`);
}
const sha = createHash('sha1').update(`blob ${content.length}\0`).update(content).digest('hex');
if (sha !== previous?.sha) {
additions.push({ path: destination, contents: content.toString('base64') });
}
}
if (additions.length === 0) {
core.info('Bundle already up to date; nothing to push.');
return;
}
fs.writeFileSync(path.join(process.env.RUNNER_TEMP, 'bundle-commit.json'), JSON.stringify({
branch: { repositoryNameWithOwner: repository, refName: `refs/heads/${expected.head.ref}` },
expectedHeadOid: expected.head.sha,
message: { headline: 'chore: rebuild action bundle for dependency update' },
fileChanges: { additions },
}));
core.setOutput('changed', 'true');
# Create the narrowly scoped token only after validation on this runner.
# The App identity ensures the commit triggers the PR's required checks.
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
if: steps.prepare.outputs.changed == 'true'
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
- name: Commit the rebuilt bundles
if: steps.prepare.outputs.changed == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const fs = require('node:fs');
const path = require('node:path');
const input = JSON.parse(fs.readFileSync(path.join(process.env.RUNNER_TEMP, 'bundle-commit.json'), 'utf8'));
// The API atomically checks the head SHA, so a concurrent push or rebase
// cannot receive bundles built from an older revision.
await github.graphql(`
mutation($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) { commit { oid } }
}
`, { input });