Skip to content

Instantly share code, notes, and snippets.

@mgaitan
Last active February 28, 2025 14:36
Show Gist options
  • Save mgaitan/d1e6f2e1410e487a7f84a0094cd0e3c8 to your computer and use it in GitHub Desktop.
Save mgaitan/d1e6f2e1410e487a7f84a0094cd0e3c8 to your computer and use it in GitHub Desktop.
User Script: Adds a button on a GitHub Actions run page to jump to the newest run on the same workflow+branch

Github Actions: Link to newest run

This is a user script that adds a button on any GitHub Actions run page (summary or job) to jump directly to the newest run on the same workflow and branch.

Why?

You push a commit, which triggers a new CI run, but you are still viewing an old run or job in GitHub Actions. Switching to the newly triggered run or job can be cumbersome. This script automatically checks for a newer run on the same workflow and branch, then shows a button to jump there. If you are on a specific job page, it even links you to the equivalent job in the newer run. This way, you avoid digging through lists of runs or re-navigating from scratch.

image

image

Installation

  1. Make sure to have Greasemonkey/Violentmonkey/Tampermonkey installed in your browser
  2. Click here
  3. Confirm your intention to install the userscript.
  4. Enable the script if needed and visit any GitHub Actions run URL. You will see a button in the top-right, near “Re-run all jobs.”
// ==UserScript==
// @name Github Actions: Link to newest run
// @namespace https://github.com
// @version 0.2
// @description Add a link "Go to newest run" or "Go to equivalent job in latest run" if there's a newer run of the same workflow+branch. Polls every 20s.
// @match https://github.com/*/actions/runs/*
// @grant none
// @author Martín Gaitán
// @homepageURL https://gist.github.com/mgaitan/d1e6f2e1410e487a7f84a0094cd0e3c8/
// @updateURL https://gist.githubusercontent.com/mgaitan/d1e6f2e1410e487a7f84a0094cd0e3c8/raw/gha_go_to_newest_run.user.js
// @downloadURL https://gist.githubusercontent.com/mgaitan/d1e6f2e1410e487a7f84a0094cd0e3c8/raw/gha_go_to_newest_run.user.js
// @match https://github.com/*/actions/runs/*
// ==/UserScript==
(function() {
'use strict';
console.log("[Debug] GH Actions: Link to newest run script loaded.");
// 1) Parse the URL to see if it's a summary or job page:
// e.g. https://github.com/OWNER/REPO/actions/runs/1234/job/5678
const urlRegex = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)(\/jobs?\/(\d+))?/;
const match = window.location.href.match(urlRegex);
if (!match) {
console.log("[Debug] Not recognized as a GitHub Actions run page; abort.");
return;
}
const owner = match[1];
const repo = match[2];
const runId = match[3];
const jobId = match[5] || null;
const isJobPage = !!jobId;
console.log("[Debug] Detected owner=%s, repo=%s, runId=%s, jobId=%s", owner, repo, runId, jobId);
// We'll need a branch name to query for newer runs.
let branchName = null;
// If on a job page, we also want to store the job's index in the nav,
// so we can map that index in the newest run.
let jobIndex = -1;
// 2) If we are on a job page, we must fetch the run summary to get the branch name.
// While we're at it, we also find the jobIndex in the current DOM if possible.
if (isJobPage) {
// Attempt to find <li data-item-id="job_<jobId>"> in the current nav
// to figure out the index among all job <li> items.
const currentJobLi = document.querySelector(`li[data-item-id="job_${jobId}"]`);
if (currentJobLi) {
const allJobLis = [...document.querySelectorAll('li[data-item-id^="job_"]')];
jobIndex = allJobLis.indexOf(currentJobLi);
console.log("[Debug] Found current job index:", jobIndex);
} else {
console.log("[Debug] Could not find nav li for job_%s in this page. We'll do best-effort fallback later.", jobId);
}
// Summaries are at /actions/runs/<runId>
const summaryUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}`;
console.log("[Debug] Will fetch summary to get branch name:", summaryUrl);
fetch(summaryUrl)
.then(resp => resp.ok ? resp.text() : Promise.reject("[Debug] Summary fetch failed: " + resp.status))
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const branchEl = doc.querySelector('a.branch-name');
if (branchEl) {
branchName = branchEl.textContent.trim() || branchEl.getAttribute('title');
console.log("[Debug] Extracted branch name from summary:", branchName);
} else {
console.log("[Debug] Could not find a.branch-name in summary. Aborting checks.");
return;
}
// Start polling
startPollingForNewest();
})
.catch(err => {
console.error("[Debug] Error fetching summary or parsing branch name:", err);
});
} else {
// Not a job page => presumably a run summary page
// We can read branch name directly from the DOM
const branchEl = document.querySelector('a.branch-name');
if (!branchEl) {
console.log("[Debug] Branch name not found in summary page. Aborting.");
return;
}
branchName = branchEl.textContent.trim() || branchEl.getAttribute('title');
console.log("[Debug] Found branchName in summary page:", branchName);
startPollingForNewest();
}
/**
* Sets an interval every 20s to check if there's a newer run.
*/
function startPollingForNewest() {
checkNewestRun();
setInterval(checkNewestRun, 20000);
}
/**
* Queries the same workflow+branch listing to see if there's a newer run ID.
*/
function checkNewestRun() {
if (!branchName) {
console.log("[Debug] Missing branchName => cannot check for newer run.");
return;
}
// We find the link to the workflow in .PageHeader-parentLink
const workflowLinkEl = document.querySelector('.PageHeader-parentLink a[href*="/actions/workflows/"]');
if (!workflowLinkEl) {
console.log("[Debug] No workflow link => fallback to 'Latest run'.");
updateOrInsertButton(null, "Latest run", null);
return;
}
const workflowUrl = workflowLinkEl.href;
const listingUrl = workflowUrl + '?query=branch:' + encodeURIComponent(branchName);
console.log("[Debug] Checking listing:", listingUrl);
fetch(listingUrl)
.then(r => r.ok ? r.text() : Promise.reject("[Debug] Listing fetch error: " + r.status))
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// The first matching run in descending order is presumably the newest
const firstRunLink = doc.querySelector('a[href*="/actions/runs/"]');
if (!firstRunLink) {
console.log("[Debug] No runs in listing => we must be the newest.");
updateOrInsertButton(null, "Latest run", null);
return;
}
const href = firstRunLink.getAttribute('href') || '';
const m = href.match(/\/runs\/(\d+)/);
if (!m) {
console.log("[Debug] Could not parse run ID => fallback to 'Latest run'.");
updateOrInsertButton(null, "Latest run", null);
return;
}
const newestRunId = m[1];
if (newestRunId === runId) {
// Already the newest
updateOrInsertButton(null, "Latest run", null);
} else {
// There's a newer run
const newestRunSummaryUrl = href.startsWith('/')
? 'https://github.com' + href
: href;
if (isJobPage && jobIndex >= 0) {
console.log("[Debug] On a job page, try mapping job index =>", jobIndex);
mapJobIndexInNewestRun(newestRunSummaryUrl, jobIndex);
} else {
// summary or no index => link to the run summary
updateOrInsertButton(newestRunSummaryUrl, null, "Go to latest run");
}
}
})
.catch(err => {
console.error("[Debug] Error fetching listing or parsing:", err);
updateOrInsertButton(null, "Latest run", null);
});
}
/**
* If we are on a job page and have a known jobIndex, fetch the newest run's summary,
* gather job <li> items, pick the same index, and link to that job. Otherwise fallback.
*/
function mapJobIndexInNewestRun(newestRunSummaryUrl, jobIndex) {
console.log("[Debug] mapJobIndexInNewestRun => summary:", newestRunSummaryUrl, "index=", jobIndex);
fetch(newestRunSummaryUrl)
.then(r => r.ok ? r.text() : Promise.reject("[Debug] Newest run summary fetch error: " + r.status))
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newRunJobLis = [...doc.querySelectorAll('li[data-item-id^="job_"]')];
console.log("[Debug] Found", newRunJobLis.length, "jobs in the newest run.");
if (jobIndex < 0 || jobIndex >= newRunJobLis.length) {
console.log("[Debug] jobIndex out of range => fallback to summary");
updateOrInsertButton(newestRunSummaryUrl, null, "Go to latest run");
return;
}
const targetLi = newRunJobLis[jobIndex];
const jobLink = targetLi.querySelector('a[href*="/job/"]');
if (!jobLink) {
console.log("[Debug] No job link found at that index => fallback to summary");
updateOrInsertButton(newestRunSummaryUrl, null, "Go to latest run");
return;
}
let finalUrl = jobLink.getAttribute('href') || '';
if (finalUrl.startsWith('/')) {
finalUrl = 'https://github.com' + finalUrl;
}
console.log("[Debug] Mapped nav index => job link:", finalUrl);
// We have a direct link to the equivalent job => use a custom label
updateOrInsertButton(finalUrl, null, "Go to equivalent job in latest run");
})
.catch(err => {
console.error("[Debug] Could not fetch or parse newest run summary:", err);
// fallback
updateOrInsertButton(newestRunSummaryUrl, null, "Go to latest run");
});
}
/**
* Inserts or updates the user-script button near "Re-run all jobs."
* If targetUrl is null => show a "Latest run" label (disabled).
* Otherwise => link with a custom text label (default "Go to latest run").
*
* @param {string|null} targetUrl
* @param {string|null} labelIfNoLink e.g. "Latest run"
* @param {string|null} linkLabel e.g. "Go to equivalent job in latest run"
*/
function updateOrInsertButton(targetUrl, labelIfNoLink, linkLabel) {
const actionsContainer = document.querySelector('.PageHeader-actions .d-none.d-md-flex');
// Remove any existing button to avoid duplicates
const existing = document.getElementById('go-to-newest-run-container');
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing);
}
const container = document.createElement('div');
container.id = 'go-to-newest-run-container';
container.style.marginRight = '8px';
if (targetUrl) {
// Create a link
const linkBtn = document.createElement('a');
linkBtn.href = targetUrl;
linkBtn.textContent = linkLabel || "Go to latest run";
linkBtn.className = 'Button--secondary Button--medium Button';
linkBtn.style.padding = '6px 12px';
linkBtn.style.textDecoration = 'none';
container.appendChild(linkBtn);
} else {
// Show a disabled-like label
const label = document.createElement('span');
label.textContent = labelIfNoLink || "Latest run";
label.className = 'Button--secondary Button--medium Button disabled';
label.style.padding = '6px 12px';
container.appendChild(label);
}
if (actionsContainer) {
actionsContainer.insertBefore(container, actionsContainer.firstChild);
} else {
// fallback if we can't find that container
document.body.insertAdjacentElement('afterbegin', container);
}
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment