Download index.html from getreadystack/gitlab-runner-compute-minutes-audit: direct link, hf CLI and curl.
- Browser
- Download file 45.1 kB
-
https://huggingface.co/spaces/getreadystack/gitlab-runner-compute-minutes-audit/resolve/main/index.html
- Command line
-
hf download hf://spaces/getreadystack/gitlab-runner-compute-minutes-audit/index.html
-
curl -L -o index.html https://huggingface.co/spaces/getreadystack/gitlab-runner-compute-minutes-audit/resolve/main/index.html
45.1 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>GitLab CI Minutes calculator — audit ci minutes by job</title> | |
| <meta name="description" content="This free browser tool ranks GitLab CI jobs by cost factor and worst-case minutes and shows runner tags to move jobs."> | |
| <style> | |
| :root{color-scheme:light}body{margin:0;font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;background:#faf9f5;color:#1a1512} | |
| .wrap{max-width:960px;margin:0 auto;padding:28px 18px 60px}h1{font-size:26px;margin:0 0 6px}.lede{color:#4a403a;margin:0 0 16px} | |
| textarea{width:100%;min-height:260px;font:13px/1.45 ui-monospace,Menlo,Consolas,monospace;padding:12px;border:1px solid #d9d0c4;border-radius:8px;box-sizing:border-box} | |
| .bar{display:flex;gap:10px;align-items:center;margin:12px 0}button{font:600 15px system-ui;padding:10px 18px;border:0;border-radius:8px;background:#1a1512;color:#fff;cursor:pointer} | |
| input[type=date]{font:14px system-ui;padding:8px;border:1px solid #d9d0c4;border-radius:6px} | |
| table{border-collapse:collapse;width:100%;font-size:14px;margin-top:10px}th,td{text-align:left;padding:8px 10px;border-bottom:1px solid #eee;vertical-align:top}th{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:#7a6e65} | |
| .sev.error{color:#a8551f;font-weight:700}.sev.warn,.sev.warning{color:#96691f}.ok{color:#2a6446;font-weight:600}.sum{font-weight:600} | |
| .note{font-size:13px;color:#7a6e65;margin-top:18px}#paid{margin-top:22px;padding:14px 16px;border:1px solid #e2d8cb;border-radius:10px;background:#fffdfa} | |
| </style> | |
| <script data-grow-initializer="">!(function(){window.growMe||((window.growMe=function(e){window.growMe._.push(e);}),(window.growMe._=[]));var e=document.createElement("script");(e.type="text/javascript"),(e.src="https://faves.grow.me/main.js"),(e.defer=!0),e.setAttribute("data-grow-faves-site-id","U2l0ZTphZjljMzgyMC0yMmU5LTRhYjgtYTIxNS03OTRhMGY1N2ExYzM=");var t=document.getElementsByTagName("script")[0];t.parentNode.insertBefore(e,t);})();</script> | |
| <!--yj-canon-s101--><link rel="canonical" href="https://getreadystack.com/tools/gitlab-runner-compute-minutes-audit"><style data-yj-mfit="1">#out{max-width:100%}#out td,#out th{overflow-wrap:anywhere}#out pre,#out code{white-space:pre-wrap;overflow-wrap:anywhere}@media (max-width:640px){table{display:block;max-width:100%;overflow-x:auto}pre{max-width:100%;overflow-x:auto}code,a{overflow-wrap:anywhere}img,svg,video,canvas,iframe{max-width:100%}textarea,input,select{max-width:100%;box-sizing:border-box}.yj-lgbar{position:static;justify-content:flex-end;margin:0 0 10px}h1,h2,h3,h4,p,li,dd,dt,blockquote,figcaption,summary,label,.res,.lead{overflow-wrap:anywhere}label{max-width:100%;box-sizing:border-box}#out table:not(:has(tr>:nth-child(4))){display:table;table-layout:fixed;width:100%}#out table:has(tr>:nth-child(4)),#out table:has(tr>:nth-child(4)) tbody,#out table:has(tr>:nth-child(4)) tr,#out table:has(tr>:nth-child(4)) td{display:block;width:auto;overflow:visible}#out table:has(tr>:nth-child(4)) thead{display:none}#out table:has(tr>:nth-child(4)) tr{padding:10px 0;border-bottom:1px solid rgba(128,128,128,.28)}#out table:has(tr>:nth-child(4)) td{border:0;padding:1px 0}#out table:has(tr>:nth-child(4)) td:nth-child(-n+3){display:inline-block;margin-right:10px;font-size:12px;opacity:.78}}</style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <h1>GitLab CI Minutes calculator — audit ci minutes by job</h1> | |
| <p class="lede">gitlab-runner compute minutes audit: ranks each .gitlab-ci.yml job by GitLab.com cost factor and worst-case minutes against the 400-minute Free quota, with the tags: line to move it to your runner. Runs entirely in your browser — nothing is uploaded.</p> | |
| <textarea id="src" spellcheck="false">stages: | |
| - build | |
| - test | |
| - package | |
| - deploy | |
| default: | |
| image: node:20 | |
| build: | |
| stage: build | |
| tags: | |
| - saas-linux-xlarge-amd64 | |
| timeout: 15m | |
| interruptible: true | |
| script: | |
| - npm ci | |
| - npm run build | |
| test: | |
| stage: test | |
| tags: | |
| - saas-linux-medium-amd64 | |
| parallel: 8 | |
| timeout: 30m | |
| interruptible: true | |
| script: | |
| - npm test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL | |
| e2e: | |
| stage: test | |
| tags: [saas-linux-medium-amd64] | |
| retry: 2 | |
| timeout: 20m | |
| interruptible: true | |
| script: | |
| - npx playwright test | |
| docker: | |
| stage: package | |
| tags: | |
| - saas-linux-medium-amd64 | |
| timeout: 10m | |
| script: | |
| - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . | |
| deploy: | |
| stage: deploy | |
| tags: | |
| - deploy-box | |
| script: | |
| - ./deploy.sh | |
| rules: | |
| - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH</textarea> | |
| <div class="bar"><button id="go">Check</button><label>as of <input type="date" id="today"></label></div> | |
| <div id="out" aria-live="polite"></div> | |
| <div id="paid"><strong>Free here:</strong> Checks one .gitlab-ci.yml and maps every job on a GitLab.com hosted runner to its cost factor and worst-case compute minutes per run, with the tags: line that moves it to your own gitlab-runner.. <strong>In the editor:</strong> Scans every .gitlab-ci.yml in the workspace in one pass and exports one dated compute-minutes report per repository, jobs ranked by worst-case minutes for the runner budget decision. — that part asks for a licence key.</div> | |
| <p class="note">Same engine as the VS Code extension, byte for byte.</p> | |
| </div> | |
| <script>var GRC_RULES = [ | |
| {"id": "high-cost-factor", "sev": "error", "title": "Hosted runner with cost factor 6 or more", "fix": "Move the job to a runner you registered with gitlab-runner (tags: [your-runner-tag]) or to saas-linux-medium-amd64 (factor 2).", "source": "https://docs.gitlab.com/ci/pipelines/compute_minutes/"}, | |
| {"id": "premium-only-size", "sev": "error", "title": "Runner size listed as Premium and Ultimate only", "fix": "On a Free namespace use saas-linux-small-amd64 or saas-linux-medium-amd64, or your own gitlab-runner.", "source": "https://docs.gitlab.com/ci/runners/hosted_runners/linux/"}, | |
| {"id": "default-timeout", "sev": "warning", "title": "No timeout: the 60-minute project default applies", "fix": "Add timeout: with the real job length, for example timeout: 15m.", "source": "https://docs.gitlab.com/ci/pipelines/settings/"}, | |
| {"id": "parallel-multiplier", "sev": "warning", "title": "parallel: multiplies compute minutes", "fix": "Lower parallel: or move the job to your own gitlab-runner.", "source": "https://docs.gitlab.com/ci/pipelines/compute_minutes/"}, | |
| {"id": "retry-multiplier", "sev": "warning", "title": "retry: repeats a paid job", "fix": "Limit retry: to runner_system_failure, or move the job to your own gitlab-runner.", "source": "https://docs.gitlab.com/ci/pipelines/compute_minutes/"}, | |
| {"id": "quota-in-one-run", "sev": "error", "title": "One job run can use the whole 400-minute Free quota", "fix": "Cut timeout, parallel or cost factor, or move the job to your own gitlab-runner.", "source": "https://docs.gitlab.com/ci/pipelines/compute_minutes/"}, | |
| {"id": "not-interruptible", "sev": "warning", "title": "Paid job is not interruptible", "fix": "Add interruptible: true so a newer pipeline can cancel it.", "source": "https://docs.gitlab.com/ci/yaml/"}, | |
| {"id": "unknown-saas-tag", "sev": "error", "title": "saas- tag that no hosted runner carries", "fix": "Use a tag from the hosted runner tables, for example saas-linux-small-amd64.", "source": "https://docs.gitlab.com/ci/runners/hosted_runners/linux/"}, | |
| {"id": "untagged-instance-job", "sev": "info", "title": "Untagged job runs on the small hosted runner", "fix": "Add tags: [your-runner-tag] to run it on your own gitlab-runner.", "source": "https://docs.gitlab.com/ci/runners/hosted_runners/linux/"}, | |
| {"id": "beta-runner", "sev": "info", "title": "macOS or Windows hosted runner (cost factor marked Beta)", "fix": "Keep macOS and Windows jobs to merge requests and the default branch, or use your own gitlab-runner.", "source": "https://docs.gitlab.com/ci/pipelines/compute_minutes/"} | |
| ] | |
| ;</script> | |
| <script>// gitlab-runner compute minutes audit — maps .gitlab-ci.yml jobs to GitLab.com cost factors. | |
| (function () { | |
| var RULES = (typeof module !== 'undefined' && typeof require !== 'undefined') ? require('./rules.json') : window.GRC_RULES; | |
| var BY_ID = {}; RULES.forEach(function (r) { BY_ID[r.id] = r; }); | |
| // Cost factors of hosted runners for GitLab.com (docs.gitlab.com/ci/pipelines/compute_minutes/) | |
| var FACTOR = { | |
| 'saas-linux-small-amd64': 1, 'saas-linux-medium-amd64': 2, 'saas-linux-large-amd64': 3, | |
| 'saas-linux-xlarge-amd64': 6, 'saas-linux-2xlarge-amd64': 12, 'saas-linux-medium-amd64-gpu-standard': 7, | |
| 'saas-linux-small-arm64': 1, 'saas-linux-medium-arm64': 2, 'saas-linux-large-arm64': 3, | |
| 'saas-macos-medium-m1': 6, 'saas-macos-large-m2pro': 12, 'saas-windows-medium-amd64': 1 | |
| }; | |
| var PREMIUM_ONLY = ['saas-linux-large-amd64', 'saas-linux-xlarge-amd64', 'saas-linux-2xlarge-amd64', 'saas-linux-medium-arm64', 'saas-linux-large-arm64']; | |
| var RESERVED = ['stages', 'variables', 'default', 'include', 'workflow', 'image', 'services', 'before_script', 'after_script', 'cache', 'spec']; | |
| var FREE_QUOTA = 400, DEFAULT_TIMEOUT = 60; | |
| function stripComment(s) { return s.replace(/(^|\s)#.*$/, ''); } | |
| function unq(s) { return String(s).trim().replace(/^["']|["']$/g, '').trim(); } | |
| function inlineList(v) { | |
| v = v.trim(); | |
| if (/^\[.*\]$/.test(v)) return v.slice(1, -1).split(',').map(unq).filter(Boolean); | |
| return v ? [unq(v)] : []; | |
| } | |
| function parseBlocks(text) { | |
| var lines = String(text || '').replace(/\r/g, '').split('\n'), blocks = [], cur = null; | |
| lines.forEach(function (raw, i) { | |
| var l = stripComment(raw); | |
| if (!l.trim()) return; | |
| var top = /^([^\s#][^:]*):\s*(.*)$/.exec(l); | |
| if (top && !/^\s/.test(l) && !/^-/.test(l)) { cur = { name: unq(top[1]), line: i + 1, lines: [] }; blocks.push(cur); return; } | |
| if (cur) cur.lines.push({ t: l, n: i + 1, ind: l.match(/^\s*/)[0].length }); | |
| }); | |
| blocks.forEach(function (b) { b.props = readProps(b); }); | |
| return blocks; | |
| } | |
| function readProps(b) { | |
| var p = {}, L = b.lines; if (!L.length) return p; | |
| var base = L[0].ind; | |
| for (var i = 0; i < L.length; i++) { | |
| if (L[i].ind !== base) continue; | |
| var m = /^\s*([A-Za-z_]+):\s*(.*)$/.exec(L[i].t); if (!m) continue; | |
| var key = m[1], val = m[2].trim(), kids = []; | |
| for (var j = i + 1; j < L.length && L[j].ind > base; j++) kids.push(L[j]); | |
| p[key] = { line: L[i].n, val: val, kids: kids }; | |
| } | |
| return p; | |
| } | |
| function listOf(prop) { | |
| if (!prop) return null; | |
| if (prop.val) return inlineList(prop.val); | |
| return prop.kids.map(function (k) { var m = /^\s*-\s*(.+)$/.exec(k.t); return m ? unq(m[1]) : null; }).filter(Boolean); | |
| } | |
| function minutes(v) { | |
| var s = String(v || '').toLowerCase(), total = 0, hit = false, re = /(\d+(?:\.\d+)?)\s*(h|hr|hrs|hours?|m|min|mins|minutes?|s|sec|secs|seconds?)\b/g, m; | |
| while ((m = re.exec(s))) { hit = true; var n = parseFloat(m[1]); total += /^h/.test(m[2]) ? n * 60 : /^s/.test(m[2]) ? n / 60 : n; } | |
| if (!hit && /^\d+$/.test(s.trim())) return parseInt(s, 10) / 60; // bare number = seconds | |
| return hit ? total : null; | |
| } | |
| function intOf(prop, child) { | |
| if (!prop) return null; | |
| if (/^\d+$/.test(prop.val)) return parseInt(prop.val, 10); | |
| for (var i = 0; i < prop.kids.length; i++) { var m = new RegExp('^\\s*' + child + ':\\s*(\\d+)').exec(prop.kids[i].t); if (m) return parseInt(m[1], 10); } | |
| return null; | |
| } | |
| function resolve(job, byName, dflt) { | |
| var out = {}, seen = {}; | |
| function apply(b, own) { ['tags', 'timeout', 'parallel', 'retry', 'interruptible', 'trigger'].forEach(function (k) { if (b.props[k]) out[k] = { p: b.props[k], own: own }; }); } | |
| function walk(b, depth) { | |
| if (!b || seen[b.name] || depth > 4) return; seen[b.name] = 1; | |
| var ex = listOf(b.props['extends']) || []; | |
| ex.forEach(function (n) { walk(byName[n], depth + 1); }); | |
| if (b !== job) apply(b, false); | |
| } | |
| if (dflt) apply(dflt, false); | |
| walk(job, 0); apply(job, true); | |
| return out; | |
| } | |
| function daysToReset(today) { | |
| var m = /(\d{4})-(\d{2})-(\d{2})/.exec(String(today || '')); if (!m) return null; | |
| var d = Date.UTC(+m[1], +m[2] - 1, +m[3]); if (isNaN(d)) return null; | |
| var next = Date.UTC(+m[1], +m[2], 1); | |
| return Math.round((next - d) / 86400000); | |
| } | |
| function check(text, opts) { | |
| opts = opts || {}; | |
| var blocks = parseBlocks(text), byName = {}, dflt = null, findings = []; | |
| blocks.forEach(function (b) { byName[b.name] = b; if (b.name === 'default') dflt = b; }); | |
| var reset = daysToReset(opts.today); | |
| function add(id, line, msg) { var r = BY_ID[id]; findings.push({ check: id, sev: r ? r.sev : 'warning', msg: msg + (r ? ' Fix: ' + r.fix : ''), line: line }); } | |
| blocks.forEach(function (job) { | |
| if (RESERVED.indexOf(job.name) >= 0 || job.name.charAt(0) === '.') return; | |
| var r = resolve(job, byName, dflt); | |
| if (r.trigger) return; // trigger jobs run on no runner | |
| var at = function (k) { return r[k] && r[k].own ? r[k].p.line : job.line; }; | |
| var tags = r.tags ? (listOf(r.tags.p) || []) : []; | |
| var saas = tags.filter(function (t) { return /^saas-/.test(t); }); | |
| var tag, factor; | |
| if (!tags.length) { tag = 'saas-linux-small-amd64'; factor = 1; } | |
| else if (!saas.length) return; // own gitlab-runner: not an instance runner | |
| else { | |
| tag = saas[0]; factor = FACTOR[tag]; | |
| if (factor == null) { add('unknown-saas-tag', at('tags'), 'Job "' + job.name + '" asks for ' + tag + ', which no GitLab.com hosted runner carries, so it stays pending.'); return; } | |
| } | |
| var tmo = r.timeout ? minutes(r.timeout.p.val) : null, noTmo = tmo == null; | |
| if (noTmo) tmo = DEFAULT_TIMEOUT; | |
| var par = Math.max(1, intOf(r.parallel && r.parallel.p, 'parallel') || 1); | |
| var retry = Math.min(2, Math.max(0, intOf(r.retry && r.retry.p, 'max') || 0)); | |
| var worst = Math.round(tmo * factor * par * (1 + retry)); | |
| var head = 'Job "' + job.name + '" on ' + tag + ' (cost factor ' + factor + '): worst case ' + worst + ' compute minutes per run'; | |
| if (!tags.length) add('untagged-instance-job', job.line, head + ' (no tags: means the default small runner).'); | |
| if (factor >= 6) add('high-cost-factor', at('tags'), head + ' = ' + Math.round(tmo) + ' min x ' + factor + '.'); | |
| if (PREMIUM_ONLY.indexOf(tag) >= 0) add('premium-only-size', at('tags'), 'Job "' + job.name + '": GitLab lists ' + tag + ' as Premium and Ultimate only.'); | |
| if (/^saas-(macos|windows)-/.test(tag)) add('beta-runner', at('tags'), head + '; GitLab marks the ' + tag + ' cost factor as Beta.'); | |
| if (noTmo && factor >= 2) add('default-timeout', job.line, head + ': no timeout:, so a hung job bills 60 min x ' + factor + '.'); | |
| if (par >= 2) add('parallel-multiplier', at('parallel'), head + ' = ' + Math.round(tmo) + ' min x ' + factor + ' x parallel ' + par + '.'); | |
| if (retry >= 1 && factor >= 2) add('retry-multiplier', at('retry'), head + ' = ' + Math.round(tmo) + ' min x ' + factor + ' x ' + (1 + retry) + ' attempts (retry: ' + retry + ').'); | |
| if (worst >= FREE_QUOTA) add('quota-in-one-run', at('tags'), head + ', over the 400-minute Free monthly quota in one run' + (reset != null ? ' (quota resets in ' + reset + (reset === 1 ? ' day' : ' days') + ', on the 1st).' : '.')); | |
| var intr = r.interruptible && /^true$/i.test(r.interruptible.p.val); | |
| if (factor >= 2 && !intr) add('not-interruptible', job.line, head + '; without interruptible: true a newer pipeline cannot cancel it.'); | |
| }); | |
| findings.sort(function (a, b) { return (a.line || 0) - (b.line || 0); }); | |
| return { findings: findings }; | |
| } | |
| var api = { engine: { check: check }, RULES: RULES, RULE_COUNT: RULES.length, FACTOR: FACTOR }; | |
| if (typeof module !== 'undefined' && module.exports) module.exports = api; | |
| if (typeof window !== 'undefined') window.GRCENGINE = api; | |
| })(); | |
| </script> | |
| <script>// ⛔뼈대 — kit/scaffold.py 가 찍는다 (s143). findings [{check,sev,msg,line?}] → 글/HTML. 같은 파일이 Node 와 브라우저에서 산다. | |
| ; | |
| function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); } | |
| function summaryLine(res) { | |
| const f = (res && res.findings) || []; const e = f.filter(function (x) { return String(x.sev || 'error').toLowerCase().startsWith('err'); }).length; | |
| return f.length ? (f.length + ' finding' + (f.length === 1 ? '' : 's') + ' (' + e + ' error' + (e === 1 ? '' : 's') + ')') : 'Clean — no findings.'; | |
| } | |
| function toText(res, label) { | |
| const f = (res && res.findings) || []; | |
| const lines = ['== ' + (label || '') + ' — ' + summaryLine(res)]; | |
| f.forEach(function (x) { lines.push(' ' + String(x.sev || 'error').toUpperCase().slice(0, 4) + ' ' + (x.line ? 'L' + x.line + ' ' : '') + (x.check || x.id || '') + ' ' + (x.msg || x.message || '')); }); | |
| return lines.join('\n'); | |
| } | |
| function toHtml(res) { | |
| const f = (res && res.findings) || []; | |
| if (!f.length) return '<p class="ok">' + escapeHtml(summaryLine(res)) + '</p>'; | |
| const rows = f.map(function (x) { return '<tr><td class="sev ' + escapeHtml(String(x.sev || 'error')) + '">' + escapeHtml(String(x.sev || 'error')) + '</td><td>' + (x.line ? 'L' + escapeHtml(x.line) : '') + '</td><td><code>' + escapeHtml(x.check || x.id || '') + '</code></td><td>' + escapeHtml(x.msg || x.message || '') + '</td></tr>'; }).join(''); | |
| return '<p class="sum">' + escapeHtml(summaryLine(res)) + '</p><table><thead><tr><th>Level</th><th>Line</th><th>Check</th><th>What</th></tr></thead><tbody>' + rows + '</tbody></table>'; | |
| } | |
| var GRCREPORT = { escapeHtml: escapeHtml, summaryLine: summaryLine, toText: toText, toHtml: toHtml }; | |
| if (typeof module !== 'undefined' && module.exports) module.exports = GRCREPORT; | |
| else window.GRCREPORT = GRCREPORT; | |
| </script> | |
| <script> | |
| (function(){ | |
| var src=document.getElementById('src'), out=document.getElementById('out'), go=document.getElementById('go'), today=document.getElementById('today'); | |
| today.value=new Date().toISOString().slice(0,10); | |
| function run(){ try { var res=window.GRCENGINE.engine.check(src.value,{today:today.value}); out.innerHTML=window.GRCREPORT.toHtml(res); } catch(e){ out.innerHTML='<p class="sev error">Could not run: '+String(e.message||e)+'</p>'; } } | |
| go.onclick=run; if (src.value.trim()) run(); | |
| })(); | |
| </script> | |
| <section data-yj-install="free" style="margin:2rem auto 1rem;max-width:46rem;padding:1.25rem 1.25rem;border:1px solid #cfe3d6;border-radius:12px;background:#f6fbf8;font:16px/1.6 system-ui,-apple-system,Segoe UI,sans-serif"><h2 style="margin:0 0 .4rem;font-size:1.2rem">Install free</h2><p style="margin:0 0 .8rem;color:#41505f">Free for the file open in your editor - no key, no limit. The workspace sweep and the report ask for a key.</p><a href="https://open-vsx.org/extension/readystack/gitlab-runner-compute-minutes-audit" rel="noopener" data-yj-install-door="ovsx" onclick="try{navigator.sendBeacon('/api/ev',new Blob([JSON.stringify({t:'install',slug:'gitlab-runner-compute-minutes-audit',src:'ovsx',path:location.pathname})],{type:'application/json'}))}catch(e){}" style="display:inline-block;margin:0 .5rem .5rem 0;padding:.6rem 1rem;border-radius:8px;background:#0f7b3f;color:#fff;text-decoration:none;font-weight:600">Open VSX (Cursor, VSCodium)</a><p style="margin:.6rem 0 0;font-size:.92rem;color:#41505f"><a href="https://getreadystack.com/api/buy/team" rel="nofollow" style="color:#0f7b3f;text-decoration:underline">Team? One key for every ReadyStack linter, 5 seats, $149 once</a></p></section> | |
| <section data-yj-alert="rule" style="margin:1rem auto 1.5rem;max-width:46rem;padding:1.1rem 1.25rem;border:1px solid #d9dee6;border-radius:12px;background:#fbfcfd;font:15px/1.55 system-ui,-apple-system,Segoe UI,sans-serif"><b style="font-size:1.05rem">Get one email when this rule changes</b><div style="opacity:.78;margin:.35rem 0 .8rem">We watch the regulation and vendor sources behind gitlab-runner Compute Minutes Audit for GitLab CI every day. When a rule changes, you get a single email with what changed and the updated check. No newsletter.</div><div style="display:flex;gap:8px;flex-wrap:wrap"><input id="yjae" type="email" placeholder="you@example.com" style="flex:1;min-width:210px;padding:11px 13px;border:1px solid rgba(140,150,165,.34);border-radius:8px;background:#fff;color:inherit"><button id="yjab" style="padding:11px 18px;border-radius:8px;border:0;cursor:pointer;background:#0f7b3f;color:#fff;font:600 15px system-ui,sans-serif">Notify me</button></div><div id="yjao" hidden style="margin-top:.6rem;color:#0f7b3f">You're on the list. One email when it changes.</div><script>(function(){var b=document.getElementById("yjab"),e=document.getElementById("yjae");if(!b||!e)return;b.addEventListener("click",function(){var v=e.value.trim();if(!/.+@.+\..+/.test(v))return;try{localStorage.setItem("yj_em","1");}catch(_){}fetch(location.origin+"/api/subscribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:v,slug:"gitlab-runner-compute-minutes-audit",lang:"en",source:"rule_alert"+((b.closest&&b.closest("section")&&b.closest("section").getAttribute("data-yj-moved"))?"_at_result":"")})}).then(function(r){var o=document.getElementById("yjao");o.hidden=false;if(!r.ok){o.textContent="Could not save - please try again.";}}).catch(function(){var o=document.getElementById("yjao");o.hidden=false;o.textContent="Could not save - please try again.";});});})();</script><script>(function(){var e=document.getElementById("yjae"),s=e&&e.closest?e.closest("section"):null,o=document.getElementById("out");if(!s||!o)return;var d=false;function mv(ev){if(d)return;if(ev&&ev.target&&s.contains(ev.target))return;d=true;var b=o;while(b.parentNode&&b.parentNode!==document.body&&b.parentNode.tagName!=="MAIN"&&b.parentNode.tagName!=="ARTICLE")b=b.parentNode;if(!b.parentNode||b===s||b.contains(s))return;try{b.parentNode.insertBefore(s,b.nextSibling);s.setAttribute("data-yj-moved","1");}catch(_){}}document.addEventListener("input",mv,true);document.addEventListener("change",mv,true);document.addEventListener("click",function(ev){var b=ev.target&&ev.target.closest?ev.target.closest("button,input[type=submit]"):null;if(b&&!b.closest("[data-yj],[data-yj-cta],[data-yj-install],[data-yj-alert],.yj-find,.yj-related,#yj-ask,.yjp"))mv(ev);},true);})();</script></section> | |
| <section data-yj-cta="paid" style="margin:3rem auto;max-width:46rem;padding:1.5rem 1.25rem;border:1px solid #d8dee9;border-radius:12px;background:#fbfcfe;font:16px/1.6 system-ui,-apple-system,Segoe UI,sans-serif"><h2 style="margin:0 0 .5rem;font-size:1.25rem">Get the full version <span class="yj-price">$29</span></h2><p class="yj-cta-paid" style="margin:.25rem 0 .6rem;color:#1f2a37">Scans every .gitlab-ci.yml in the workspace and exports one dated compute-minutes report per repository, jobs ranked by worst-case minutes.</p><p class="yj-cta-pay" style="margin:.25rem 0 1rem;color:#5b6b7c;font-size:.94rem">One payment, one licence key for this tool. The key is shown right after payment.</p><p class="yj-cta-anchor" style="margin:0 0 1rem;color:#5b6b7c;font-size:.94rem">Extra GitLab compute minutes cost $10 per 1,000; Premium is $29 per user/month for 10,000 minutes.</p><a href="https://getreadystack.com/api/buy/cl/polar_cl_hi8ftbz17NX5rmsAOceJ91YjKxTeQm1bk7pM84Odko8" rel="nofollow" style="display:inline-block;padding:.7rem 1.3rem;border-radius:8px;background:#1b64f2;color:#fff;text-decoration:none;font-weight:600">Buy the full version — $29</a></section> | |
| <!--yj-share-s101--><div style="margin:28px 0 8px;text-align:center"><button id="yjcp" type="button" style="font:inherit;padding:10px 18px;border:1px solid #8884;border-radius:10px;background:transparent;cursor:pointer">Copy link</button><button id="yjimg" type="button" style="font:inherit;padding:10px 18px;margin-left:8px;border:1px solid #8884;border-radius:10px;background:transparent;cursor:pointer">Save result as image</button></div><script>(function(){var b=document.getElementById("yjimg");if(!b)return;b.addEventListener("click",function(){var o=document.getElementById("out");var txt=(o&&(o.innerText||o.textContent)||"").trim();if(!txt){b.textContent="(no result yet)";setTimeout(function(){b.textContent="Save result as image";},1500);return;}var lines=txt.split(String.fromCharCode(10)).filter(function(x){return x.trim();}).slice(0,10);var W=1200,P=56,c=document.createElement("canvas");c.width=W;var lh=38,H=P*2+70+lines.length*lh+60;c.height=H;var x=c.getContext("2d");x.fillStyle="#1a1512";x.fillRect(0,0,W,H);x.fillStyle="#f3e9d2";x.font="600 34px system-ui,Segoe UI,Arial";var t=(document.title||"").split("|")[0].trim();x.fillText(t.slice(0,54),P,P+34);x.font="22px ui-monospace,Menlo,Consolas,monospace";x.fillStyle="#e8dcc4";for(var i=0;i<lines.length;i++){x.fillText(lines[i].slice(0,88),P,P+70+i*lh+22);}x.font="600 22px system-ui,Segoe UI,Arial";x.fillStyle="#c9a227";var d=location.hostname.replace("www.","");x.fillText(d,W-P-x.measureText(d).width,H-P+6);c.toBlob(function(bl){var u=URL.createObjectURL(bl),a=document.createElement("a");a.href=u;a.download=(t||"result").replace(/[^a-z0-9]+/gi,"-").toLowerCase()+"-"+d+".png";document.body.appendChild(a);a.click();setTimeout(function(){URL.revokeObjectURL(u);a.remove();},2000);},"image/png");});})();</script><script>(function(){var b=document.getElementById("yjcp");if(!b)return;var t=b.textContent;b.addEventListener("click",function(){var u=location.href.split("#")[0];function d(){b.textContent="Link copied";setTimeout(function(){b.textContent=t;},1800);}if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(u).then(d,function(){prompt("",u);});}else{prompt("",u);}});})();</script> | |
| <script data-yj="yj-view-beacon">(function(){try{var p=location.pathname;var k='yjv:'+p;if(sessionStorage.getItem(k))return;sessionStorage.setItem(k,'1');fetch('/api/ev',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p,lang:"en",ref:(document.referrer||''),src:(function(){try{return (new URLSearchParams(location.search).get('ref')||'').replace(/[^a-z_]/g,'').slice(0,12)}catch(e){return ''}})()})}).catch(function(){});}catch(e){}})();</script> | |
| <script data-yj="yj-use-beacon">(function(){try{var o=document.getElementById('out');if(!o)return;var p=location.pathname,k='yju:'+p,sent=false;function fire(){if(sent||sessionStorage.getItem(k))return;if(!(o.textContent||'').replace(/\s+/g,'').length)return;sent=true;sessionStorage.setItem(k,'1');fetch('/api/ev',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p,lang:"en",t:'use',src:(function(){try{return (new URLSearchParams(location.search).get('ref')||'').replace(/[^a-z_]/g,'').slice(0,12)}catch(e){return ''}})()})}).catch(function(){});}var mo=new MutationObserver(function(){fire();if(sent)mo.disconnect();});mo.observe(o,{childList:true,characterData:true,subtree:true});}catch(e){}})();</script><div class="yj-home-find"><div class="yj-lgbar"><a href="/" class="yj-lg on">EN</a><a href="/de/" class="yj-lg">DE</a><a href="/ja/" class="yj-lg">JA</a><a href="/es/" class="yj-lg">ES</a><a href="/pt/" class="yj-lg">PT</a></div><section class="yj-find"><h2>Find a tool</h2><input id="yjq" type="search" autocomplete="off" placeholder="Type a product name, format or task"><div id="yjr"></div></section><style>.yj-lgbar{position:absolute;top:14px;right:18px;display:flex;gap:6px;z-index:60}.yj-lg{font:600 12px/1 system-ui,sans-serif;padding:6px 9px;border-radius:4px;text-decoration:none;color:#8b96a3;border:1px solid rgba(140,150,165,.28)}.yj-lg:hover{color:#e8eaed;border-color:rgba(160,175,195,.6)}.yj-lg.on{color:#0e1116;background:#cfd6de;border-color:#cfd6de}.yj-find{max-width:760px;margin:34px auto 8px;padding:0 20px}.yj-find h2{font:600 20px/1.3 system-ui,sans-serif;margin:0 0 10px}#yjq{width:100%;box-sizing:border-box;padding:13px 15px;font:400 15.5px system-ui,sans-serif;border:1px solid rgba(140,150,165,.34);border-radius:8px;background:rgba(255,255,255,.04);color:inherit}#yjq:focus{outline:none;border-color:#7FD1C4}#yjr{margin-top:10px;display:flex;flex-direction:column;gap:6px}#yjr a{display:flex;justify-content:space-between;gap:12px;padding:11px 14px;border:1px solid rgba(140,150,165,.22);border-radius:6px;text-decoration:none;font:400 14.5px/1.4 system-ui,sans-serif;color:inherit}#yjr a:hover{border-color:#7FD1C4}#yjr .pr{font:600 13px system-ui,sans-serif;color:#5fbf9a;white-space:nowrap}</style><script>(function(){var q=document.getElementById("yjq"),r=document.getElementById("yjr"),D=null;function load(){if(D)return Promise.resolve(D);return fetch("/search-index.json").then(function(x){return x.json();}).then(function(j){D=j;return j;}).catch(function(){D=[];return D;});}function esc(t){return String(t).replace(/[&<>"]/g,function(c){return {"&":"&","<":"<",">":">",'"':"""}[c];});}function run(){var v=q.value.trim().toLowerCase();if(v.length<2){r.innerHTML="";return;}load().then(function(j){var w=v.split(/\s+/),o=[];for(var i=0;i<j.length&&o.length<12;i++){var t=j[i].t.toLowerCase();var ok=true;for(var k=0;k<w.length;k++){if(t.indexOf(w[k])<0){ok=false;break;}}if(ok)o.push(j[i]);}r.innerHTML=o.length?o.map(function(x){return "<a href=\""+esc(x.u)+"\">"+"<span>"+esc(x.t)+"</span>"+(x.p?"<span class=\"pr\">$"+x.p+"</span>":"")+"</a>";}).join(""):"";});}q.addEventListener("input",run);q.addEventListener("focus",load);})();</script></div><section data-yj="yj-facts-v1" style="max-width:920px;margin:48px auto 0;padding:0 20px;font-family:system-ui,sans-serif"><style>.yj-article p{font:400 15.5px/1.7 system-ui,sans-serif;color:#26313b;margin:0 0 12px;max-width:72ch}.yj-article h2,.yj-article h3,.yj-article h4{font:600 17px/1.35 system-ui,sans-serif;color:#1b1f24;margin:22px 0 8px}.yj-article table{border-collapse:collapse;margin:10px 0 16px;font:14px/1.45 system-ui,sans-serif}.yj-article th,.yj-article td{border:1px solid #e2e6ea;padding:6px 10px;text-align:left}.yj-article th{background:#f4f6f8}.yj-article pre{background:#0f1720;color:#e6edf3;padding:12px 14px;border-radius:8px;overflow:auto;font:13px/1.5 ui-monospace,Menlo,monospace}.yj-article code{font:13px ui-monospace,Menlo,monospace;background:#f1f3f5;padding:1px 4px;border-radius:3px}.yj-article pre code{background:none;padding:0}.yj-article ul,.yj-article ol{font:15px/1.6 system-ui,sans-serif;color:#26313b}</style><p style="font:400 13px/1.5 system-ui,sans-serif;color:#6b7680;margin:0 0 14px"><time datetime="2026-10-01">Updated 2026-10-01</time> · ReadyStack</p><script type="application/ld+json">{"@context":"https://schema.org","@type":"WebPage","dateModified":"2026-10-01","author":{"@type":"Organization","name":"ReadyStack"}}</script><h2 style="font:600 21px/1.3 system-ui,sans-serif;margin:0 0 6px;color:#1b1f24">Worked example</h2><p style="font:400 14.5px/1.5 system-ui,sans-serif;margin:0 0 16px;color:#5b6570">Real numbers from this tool, line by line.</p><div class="yj-article"><p><img src="https://getreadystack.com/img/promo/sku430659_result_card.jpg" alt="Migrate from GitLab.com hosted runners to Gitlab-Runner: find the jobs that burn your 400 compute minutes" loading="lazy" style="max-width:100%;border-radius:6px"></p> | |
| <p>gitlab-runner compute minutes: 6 findings in one five-job .gitlab-ci.yml, and a single test job that can use 480 compute minutes per run. That is written for DevOps engineers and small teams on GitLab.com Free in the US, the UK and the EU, where a namespace gets 400 compute minutes a month and usage resets to 0 on the first day of each month.</p> | |
| <h3>What GitLab counts</h3> | |
| <p>GitLab's documentation (docs.gitlab.com/ci/pipelines/compute_minutes/, read 2026-09-30) gives the formula: Job duration / 60 * Cost factor. The cost factor comes from the runner tag: saas-linux-small-amd64 is 1, medium 2, large 3, xlarge 6, 2xlarge 12, the GPU runner 7, macOS M1 6 and macOS M2 Pro 12 (the macOS and Windows factors are marked Beta). Untagged jobs run on the small Linux runner. Free gets 400 minutes, Premium 10,000 and Ultimate 50,000 a month. Extra minutes are sold at $10 per 1,000 as a one-time pack.</p> | |
| <p>Jobs on a runner you registered yourself with gitlab-runner are not instance runners, and compute minutes measure instance runners. That is the migration path: move the expensive jobs, keep the cheap ones.</p> | |
| <h3>The sample file</h3> | |
| <p>The sample pipeline has five jobs: build on saas-linux-xlarge-amd64 with timeout 15m, test on medium with parallel: 8 and timeout 30m, e2e on medium with retry: 2 and timeout 20m, docker on medium with timeout 10m, and deploy on a self-hosted runner tagged deploy-box.</p> | |
| <p>The audit resolves default: and extends: for every job and takes the worst case for one run: timeout (or the 60-minute default) x cost factor x parallel x (1 + retry). It reports 6 findings from 10 rules:</p> | |
| <ul><li>build: cost factor 6 (xlarge), 15 min x 6 = 90 compute minutes. Fix: tags: [your-runner-tag].</li><li>build: xlarge is Premium and Ultimate only. Fix: saas-linux-medium-amd64 (factor 2).</li><li>test: 480 compute minutes per run, more than the whole Free month. Fix: cut timeout or parallel.</li><li>test: 30 min x 2 x parallel 8. Fix: lower parallel:.</li><li>e2e: retry: 2 = 3 attempts, 120 compute minutes. Fix: retry: when: runner_system_failure.</li><li>docker: not interruptible, so a newer pipeline cannot cancel it. Fix: interruptible: true.</li></ul> | |
| <p>deploy produces nothing: it already runs on deploy-box, a self-hosted gitlab-runner, so it costs 0 compute minutes.</p> | |
| <h3>Moving a job</h3> | |
| <p>Register a runner with gitlab-runner register, give it a tag such as build-box, and swap the hosted tag:</p> | |
| <p>build: tags:</p> | |
| <ul><li>build-box # was saas-linux-xlarge-amd64 (cost factor 6)</li></ul> | |
| <p>timeout: 15m interruptible: true</p> | |
| <p>Run the check again and the build findings disappear. A file where every job runs on your own runners gives 0 findings.</p> | |
| <h3>Why the usage page is not enough</h3> | |
| <p>GitLab's usage page shows minutes after a pipeline has spent them. A chatbot does not see your tags:, parallel: and retry: lines, and often quotes an older cost factor table. The check reads the file you are editing and names the line before the next push.</p> | |
| <h3>Free and full version</h3> | |
| <p>The free extension and the free web page check one .gitlab-ci.yml with no limit on runs, and nothing leaves your machine. A licence key adds a scan of every .gitlab-ci.yml in the workspace and one dated compute-minutes report per repository, jobs ranked by worst-case minutes, for the runner budget decision.</p></div><h2 style="font:600 19px/1.3 system-ui,sans-serif;margin:30px 0 10px;color:#1b1f24">15 seconds — what it actually does</h2><video controls muted playsinline preload="none" poster="/img/promo/sku430659_result_card.jpg" style="width:100%;max-width:360px;border-radius:10px;display:block;margin:0 auto"><source src="/media/sku430659_short.mp4" type="video/mp4"></video><h2 style="font:600 21px/1.3 system-ui,sans-serif;margin:34px 0 6px;color:#1b1f24">Questions people ask</h2><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">What does gitlab-runner Compute Minutes Audit do?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">It reads a .gitlab-ci.yml, resolves default: and extends:, and maps every job on a GitLab.com hosted runner to its cost factor and worst-case compute minutes per run (timeout x cost factor x parallel x attempts). It applies 10 rules; on the bundled sample file it reports 6 findings, each with the file line and the tags: line that moves the job to your own gitlab-runner.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">Who is it for?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">DevOps engineers, platform teams and solo maintainers on GitLab.com Free, which gives 400 compute minutes a month and 5 users per top-level group, and Premium teams watching a 10,000-minute quota. It helps anyone deciding which CI jobs to move from hosted runners such as saas-linux-xlarge-amd64 to a self-hosted gitlab-runner before the quota runs out.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">Why not just use GitLab's usage page or a chatbot?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">GitLab's compute usage page shows minutes after a pipeline has spent them. A chatbot does not see your tags:, parallel: and retry: lines and often quotes an old cost factor table. This extension reads the actual file, applies the published factors such as 6 for xlarge and 12 for macOS M2 Pro, and names the exact line to change before the next run.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">What is free and what needs a licence key?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">Free: the open .gitlab-ci.yml, every hosted-runner job with its cost factor, worst-case minutes and fix line, with no limit on runs. The licence key adds a workspace scan of every .gitlab-ci.yml and one dated compute-minutes report per repository, jobs ranked by worst-case minutes, for the runner budget decision. One payment, one key per person or team seat.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">What does running out of compute minutes cost?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">GitLab sells extra compute minutes at $10 per 1,000 minutes as a one-time pack, and Premium costs $29 per user per month for 10,000 minutes. In the sample file one parallel: 8 test job on a medium runner can use 480 minutes in one run, more than the 400 minutes a Free namespace gets each month.</p></details><script type="application/ld+json" data-yj="yj-faq-ld">{"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [{"@type": "Question", "name": "What does gitlab-runner Compute Minutes Audit do?", "acceptedAnswer": {"@type": "Answer", "text": "It reads a .gitlab-ci.yml, resolves default: and extends:, and maps every job on a GitLab.com hosted runner to its cost factor and worst-case compute minutes per run (timeout x cost factor x parallel x attempts). It applies 10 rules; on the bundled sample file it reports 6 findings, each with the file line and the tags: line that moves the job to your own gitlab-runner."}}, {"@type": "Question", "name": "Who is it for?", "acceptedAnswer": {"@type": "Answer", "text": "DevOps engineers, platform teams and solo maintainers on GitLab.com Free, which gives 400 compute minutes a month and 5 users per top-level group, and Premium teams watching a 10,000-minute quota. It helps anyone deciding which CI jobs to move from hosted runners such as saas-linux-xlarge-amd64 to a self-hosted gitlab-runner before the quota runs out."}}, {"@type": "Question", "name": "Why not just use GitLab's usage page or a chatbot?", "acceptedAnswer": {"@type": "Answer", "text": "GitLab's compute usage page shows minutes after a pipeline has spent them. A chatbot does not see your tags:, parallel: and retry: lines and often quotes an old cost factor table. This extension reads the actual file, applies the published factors such as 6 for xlarge and 12 for macOS M2 Pro, and names the exact line to change before the next run."}}, {"@type": "Question", "name": "What is free and what needs a licence key?", "acceptedAnswer": {"@type": "Answer", "text": "Free: the open .gitlab-ci.yml, every hosted-runner job with its cost factor, worst-case minutes and fix line, with no limit on runs. The licence key adds a workspace scan of every .gitlab-ci.yml and one dated compute-minutes report per repository, jobs ranked by worst-case minutes, for the runner budget decision. One payment, one key per person or team seat."}}, {"@type": "Question", "name": "What does running out of compute minutes cost?", "acceptedAnswer": {"@type": "Answer", "text": "GitLab sells extra compute minutes at $10 per 1,000 minutes as a one-time pack, and Premium costs $29 per user per month for 10,000 minutes. In the sample file one parallel: 8 test job on a medium runner can use 480 minutes in one run, more than the 400 minutes a Free namespace gets each month."}}]}</script><div id="yj-ask" style="margin:36px 0 0;padding:18px 18px 16px;border:1px solid #e2e6ea;border-radius:10px;background:#fbfbfc"><h2 style="font:600 19px/1.3 system-ui,sans-serif;margin:0 0 4px;color:#1b1f24">Ask about this tool</h2><p style="font:400 13.5px/1.5 system-ui,sans-serif;margin:0 0 12px;color:#5b6570">One question, answered by the person who built it. Your email only if you want the answer sent.</p><form onsubmit="return yjAsk(event)" style="display:grid;gap:8px"><textarea name="question" required minlength="10" maxlength="2000" rows="3" placeholder="Your question" style="font:14px/1.5 system-ui,sans-serif;padding:10px;border:1px solid #cfd6dd;border-radius:6px"></textarea><div style="display:flex;gap:8px;flex-wrap:wrap"><input type="email" name="email" placeholder="Email (optional)" style="flex:1;min-width:200px;font:14px system-ui,sans-serif;padding:9px 10px;border:1px solid #cfd6dd;border-radius:6px"><button type="submit" style="font:600 14px system-ui,sans-serif;padding:9px 18px;border:0;border-radius:6px;background:#1b1f24;color:#fff;cursor:pointer">Send</button></div><p id="yj-ask-msg" style="font:13px system-ui,sans-serif;margin:2px 0 0;color:#0a6238;min-height:1em"></p></form><script>function yjAsk(e){e.preventDefault();var f=e.target,m=document.getElementById("yj-ask-msg");m.style.color="#0a6238";fetch(location.origin+"/api/ask",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sku:430659,slug:"gitlab-runner-compute-minutes-audit",lang:"en",question:f.question.value,email:f.email.value,page:location.pathname})}).then(function(r){if(!r.ok)throw 0;m.textContent="Thanks \u2014 noted. Answers land on this page and, if you left an email, in your inbox.";f.question.value="";}).catch(function(){m.style.color="#a8551f";m.textContent="Could not send. Please try again in a minute.";});return false;}</script></div></section><section class="yj-related" style="max-width:920px;margin:56px auto 40px;padding:0 20px"><h2 style="font:600 21px/1.3 system-ui,sans-serif;margin:0 0 6px;color:#1b1f24">Same job, next tool</h2><p style="font:400 14.5px/1.5 system-ui,sans-serif;margin:0 0 20px;color:#5b6570">Free in the browser. The full version is a one-time licence.</p><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px"><a class="yj-rel-c" href="https://getreadystack.com/tools/content-calendar-template"><span class="yj-rel-t">Content Calendar Template with Capacity & Cost Calculator for Small Content Te</span><span class="yj-rel-p">$90</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/photographer-portfolio-template"><span class="yj-rel-t">Photographer Portfolio Template (self-contained HTML) — 24 pages with licensin</span><span class="yj-rel-p">$49</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/portfolio-website-template"><span class="yj-rel-t">Portfolio Website Template — Freelance Designer Portfolio & Case Study Site (8</span><span class="yj-rel-p">$49</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/webflow-restaurant-template"><span class="yj-rel-t">Webflow Restaurant Template — delivered as self-contained HTML you own: 26 pag</span><span class="yj-rel-p">$49</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/base-image-eol-lint"><span class="yj-rel-t">Base Image EOL Lint - Dockerfile and CI end-of-life check</span><span class="yj-rel-p">$29</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/bulk-sender-lint"><span class="yj-rel-t">Bulk Sender Lint - the Gmail, Yahoo and Outlook.com rules, checked in your mai</span><span class="yj-rel-p">$29</span></a></div><style>.yj-rel-c{display:flex;flex-direction:column;gap:8px;padding:15px 16px;border:1px solid #e2e6ea;border-radius:6px;text-decoration:none;background:#fff}.yj-rel-c:hover{border-color:#b9c2cb;background:#fafbfc}.yj-rel-t{font:500 14.5px/1.42 system-ui,sans-serif;color:#1b1f24}.yj-rel-p{font:600 13px system-ui,sans-serif;color:#0a6238}</style></section></body> | |
| </html> | |