diff --git a/.github/workflows/publish-direct.yml b/.github/workflows/publish-direct.yml index 11339ba..4fd58a0 100644 --- a/.github/workflows/publish-direct.yml +++ b/.github/workflows/publish-direct.yml @@ -26,11 +26,13 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Check out translations + uses: actions/checkout@v4 with: path: translations - - uses: actions/checkout@v4 + - name: Check out translations-templates + uses: actions/checkout@v4 with: repository: GenAI-Security-Project/translations-templates ssh-key: ${{ secrets.TEMPLATES_DEPLOY_KEY }} diff --git a/docs/README.md b/docs/README.md index c494c64..14ce5af 100644 --- a/docs/README.md +++ b/docs/README.md @@ -124,6 +124,31 @@ the payload always leaves `asset` unset for a new upload, so `bootstrap_asset.py` derives the real, authoritative id server-side. Never trust the browser's copy as the source of truth. +## Live status bars after submitting + +Both `upload.html` (after `bootstrap-asset.yml` fires) and `publish.html` +(after each `publish-direct.yml` fire — one per checked asset/locale row) +show a live status bar tracking the actual Actions run, instead of just +pointing the user at the Actions tab: current step, a progress bar, and a +link to the run once it's found. `js/workflow-status.js` implements this; +`js/upload.js`/`js/publish.js` just create one `WorkflowStatusBar` per +dispatch and call `.track(workflowFile, excludeIds)`. + +The one real wrinkle: `POST /dispatches` returns no run id (that's just how +`repository_dispatch` works), so a bar can't be told "watch run #12345" — +it has to find its own run by elimination. `excludeIds` is a snapshot of +that workflow's existing run ids, taken *immediately before* the dispatch +call; the bar then polls the run list until an id shows up that wasn't in +that snapshot, and attributes that one to itself. This is unambiguous for +`upload.html` (one dispatch per submit), but `publish.html` can fire several +dispatches back to back — there, each request's bar must finish attributing +its run (and add that id to the shared exclude set) *before* the next +request's dispatch fires, or two requests started close together could both +claim the same freshly-appeared run. Polling a run to completion still +happens concurrently across requests after that — only the attribution step +is serialized. See `workflow-status.js`'s header comment and `publish.js`'s +`handlePublish` for exactly where that ordering matters. + ## Duplicate-asset detection `bootstrap_asset.py`'s own "derived id already exists" refusal only catches diff --git a/docs/css/style.css b/docs/css/style.css index 3f09c06..20b04fb 100644 --- a/docs/css/style.css +++ b/docs/css/style.css @@ -199,6 +199,52 @@ button.secondary { background: transparent; color: var(--accent); border: 1px so .status-pill-progress { background: rgba(138, 90, 0, 0.15); color: var(--warn); } .status-pill-done { background: rgba(27, 122, 61, 0.15); color: var(--ok); } +/* Live workflow-run status bars -- see js/workflow-status.js. */ +.status-bar-row { margin-top: 14px; } +.status-bar { + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 14px; + margin-top: 10px; + background: var(--field-bg); +} +.status-bar-label { font-weight: 600; margin-bottom: 6px; } +.status-bar-message { margin: 0 0 8px; font-size: 0.92rem; } +.status-bar-message-error { color: var(--error); } +.status-bar-track { + height: 8px; + border-radius: 999px; + background: var(--border); + overflow: hidden; +} +.status-bar-track > div { height: 100%; transition: width 0.3s ease; } +.status-bar-fill-progress { background: var(--accent); } +.status-bar-fill-success { background: var(--ok); } +.status-bar-fill-failure { background: var(--error); } +.status-bar-actions-heading { + margin: 12px 0 2px; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--muted); +} +.status-bar-steps { + list-style: none; + padding: 0; + margin: 10px 0 0; + font-size: 0.85rem; + color: var(--muted); +} +.status-bar-step { padding: 2px 0; } +.status-bar-step-icon { display: inline-block; width: 1.4em; text-align: center; } +.status-bar-step-success .status-bar-step-icon { color: var(--ok); } +.status-bar-step-failure .status-bar-step-icon, .status-bar-step-cancelled .status-bar-step-icon { color: var(--error); } +.status-bar-step-in_progress { color: var(--fg); font-weight: 600; } +.status-bar-step-in_progress .status-bar-step-icon { color: var(--accent); } +.status-bar-link { display: inline-block; margin-top: 10px; font-size: 0.85rem; color: var(--accent); text-decoration: none; } +.status-bar-link:hover { text-decoration: underline; } + .duplicate-warning-soft, .duplicate-block { border-radius: 8px; padding: 10px 14px; diff --git a/docs/js/github-api.js b/docs/js/github-api.js index 304e579..644d248 100644 --- a/docs/js/github-api.js +++ b/docs/js/github-api.js @@ -143,3 +143,22 @@ async function dispatchPublishDirect(clientPayload) { body: JSON.stringify({ event_type: "publish-direct", client_payload: clientPayload }), }); } + +// Most recent runs of one workflow file, newest first -- used by +// workflow-status.js to find the run a dispatch just created (the +// dispatches API itself returns no run id, see its call sites above) and +// then to poll that run's own progress. Never cached: the whole point is +// watching it change. +async function listWorkflowRuns(workflowFile, perPage = 10) { + const data = await ghFetch(`/repos/${ORG}/${CONTENT_REPO}/actions/workflows/${workflowFile}/runs?per_page=${perPage}`); + return data.workflow_runs || []; +} + +// One run's jobs, each with its own steps[] (name/status/conclusion) -- +// the real, step-by-step progress a status bar renders. A run usually has +// exactly one job here (every workflow this pipeline fires does), but the +// API always returns a list. +async function getWorkflowRunJobs(runId) { + const data = await ghFetch(`/repos/${ORG}/${CONTENT_REPO}/actions/runs/${runId}/jobs`); + return data.jobs || []; +} diff --git a/docs/js/publish.js b/docs/js/publish.js index 40ab62a..75e7180 100644 --- a/docs/js/publish.js +++ b/docs/js/publish.js @@ -139,27 +139,55 @@ async function handlePublish(event) { const includeWatermark = document.getElementById("include-watermark-checkbox").checked; const publishButton = document.getElementById("publish-button"); + const barsContainer = document.getElementById("publish-status-bars"); publishButton.disabled = true; resultEl.textContent = `Submitting ${checked.length} publish request(s)…`; resultEl.className = ""; + barsContainer.innerHTML = ""; + + // Snapshotted once, before the first dispatch -- each request's status + // bar adds its own attributed run id as it's found, so the next + // request's attribution can't mistake an already-claimed run for its + // own. See workflow-status.js's header comment for why this can't just + // be done per-request after the fact. + let excludeIds; + try { + excludeIds = await snapshotRunIds("publish-direct.yml"); + } catch (_) { + excludeIds = new Set(); // best effort -- attribution may then take a moment longer, not fail outright + } const successes = []; const failures = []; + const pending = []; for (const checkbox of checked) { const { asset, locale } = checkbox.dataset; + const label = `${asset} / ${locale}`; + const barEl = document.createElement("div"); + barEl.className = "status-bar-row"; + barsContainer.appendChild(barEl); + const bar = new WorkflowStatusBar(barEl, label); + try { await dispatchPublishDirect({ asset, locale, watermark: includeWatermark, totp_code: totpCode }); - successes.push(`${asset} / ${locale}`); + successes.push(label); } catch (err) { - failures.push(`${asset} / ${locale}: ${err.message}`); + failures.push(`${label}: ${err.message}`); + bar.renderError(err.message); + continue; } + + // Attribute this request's run *before* the next dispatch fires (see + // above), then let it poll to completion in the background so waiting + // for one slow run doesn't hold up submitting the rest. + const runId = await bar.attributeRun("publish-direct.yml", excludeIds); + if (runId != null) excludeIds.add(runId); + pending.push(bar.pollUntilDone(runId)); } let summary = ""; if (successes.length) { - summary += `✓ Submitted: ${successes.join(", ")}. ` + - `Check the Actions tab for progress, ` + - `and each asset/locale's release/ folder or the repo's Releases once done.`; + summary += `✓ Submitted: ${successes.join(", ")}. Progress for each is shown below.`; } if (failures.length) { summary += `${summary ? "
" : ""}✗ Failed to submit: ${failures.join("; ")}`; @@ -167,6 +195,11 @@ async function handlePublish(event) { resultEl.innerHTML = summary; resultEl.className = failures.length ? "result-error" : "result-ok"; publishButton.disabled = false; + + // Not awaited -- the form handler is done; each bar renders its own + // outcome as its run progresses. Kept only so nothing here is treated as + // an unhandled/orphaned promise. + Promise.allSettled(pending); } document.addEventListener("DOMContentLoaded", () => { diff --git a/docs/js/upload.js b/docs/js/upload.js index 45542ec..22fa611 100644 --- a/docs/js/upload.js +++ b/docs/js/upload.js @@ -343,10 +343,21 @@ async function handleSubmit(event) { // asset is left unset — bootstrap_asset.py derives the real id server-side } + // Shown above the status bar once submitted, so it's clear which asset a + // given run belongs to (matters most once more than one upload has been + // submitted from this tab). For a new asset this is only the *preview* id + // (see deriveAssetIdPreview's own caveats) -- the real one is always + // derived server-side and could in principle differ. + const statusBarLabel = mode === "existing" + ? payload.asset + : deriveAssetIdPreview(document.getElementById("file-input").files[0].name); + const submitButton = document.getElementById("submit-button"); + const statusBarEl = document.getElementById("submit-status-bar"); submitButton.disabled = true; - resultEl.textContent = "Uploading…"; + resultEl.textContent = ""; resultEl.className = ""; + statusBarEl.innerHTML = ""; try { if (mode === "new") { @@ -360,16 +371,25 @@ async function handleSubmit(event) { payload.uploaded_path = incomingPath; } + // Snapshotted immediately before dispatching -- see workflow-status.js's + // header comment on why this has to happen before, not after. + const beforeRunIds = await snapshotRunIds("bootstrap-asset.yml"); await dispatchBootstrapAsset(payload); - resultEl.innerHTML = - "✓ Submitted. bootstrap-asset is now running — check the " + - `Actions tab ` + - "for progress, and the repo's Pull Requests once it opens the onboarding PR."; + resultEl.innerHTML = "✓ Submitted."; resultEl.className = "result-ok"; + submitButton.disabled = false; // re-enabled once dispatched -- tracking below can run for minutes and shouldn't block a second submission + + const bar = new WorkflowStatusBar(statusBarEl, statusBarLabel); + const run = await bar.track("bootstrap-asset.yml", beforeRunIds); + if (run && run.conclusion === "success") { + resultEl.innerHTML = + "✓ Onboarding finished — check the repo's " + + `Pull Requests ` + + "for the onboarding PR it opened. Merging that PR starts drafting the translation(s) for any newly added locale."; + } } catch (err) { resultEl.textContent = `✗ ${err.message}`; resultEl.className = "result-error"; - } finally { submitButton.disabled = false; } } diff --git a/docs/js/workflow-status.js b/docs/js/workflow-status.js new file mode 100644 index 0000000..6d908d4 --- /dev/null +++ b/docs/js/workflow-status.js @@ -0,0 +1,192 @@ +// A live status bar for one GitHub Actions run, built on the polling +// helpers in github-api.js (listWorkflowRuns, getWorkflowRunJobs). Used by +// upload.js (bootstrap-asset.yml) and publish.js (publish-direct.yml) -- +// both fire a repository_dispatch, which returns no run id, so the only +// way to show real progress is: snapshot existing run ids before +// dispatching, then poll the workflow's run list until a run id shows up +// that wasn't in that snapshot (see attributeRun), then poll that run's +// own jobs/steps until it finishes (see pollUntilDone). +// +// Two dispatches of the same workflow fired close together can't be told +// apart any other way -- the runs API doesn't expose a dispatch's +// client_payload -- so callers that fire more than one dispatch (publish.js) +// must attribute each one (wait for its run to appear) *before* firing the +// next, even though each bar then polls its own run to completion +// independently/concurrently after that. See publish.js. + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const RUN_URL = (runId) => `https://github.com/GenAI-Security-Project/translations/actions/runs/${runId}`; + +const STEP_ICON = { + pending: "○", + in_progress: "◐", + success: "✓", + failure: "✗", + skipped: "–", + cancelled: "✗", +}; + +function stepDisplayStatus(step) { + if (step.status !== "completed") return step.status === "in_progress" ? "in_progress" : "pending"; + return step.conclusion || "success"; +} + +class WorkflowStatusBar { + // container: an element this renders into (its innerHTML is fully owned + // once tracking starts). label: short text identifying what this bar is + // tracking, e.g. an asset id or "asset / locale" pair, shown as a heading + // when a page is running more than one of these at once (publish.js). + constructor(container, label) { + this.container = container; + this.label = label; + this.runId = null; + this._renderWaiting("Submitted — waiting for the workflow to start…"); + } + + _shell(bodyHtml) { + const heading = this.label ? `
${this.label}
` : ""; + this.container.innerHTML = `
${heading}${bodyHtml}
`; + } + + _renderWaiting(message) { + this._shell(`

${message}

`); + } + + _renderError(message) { + this._shell(`

✗ ${message}

`); + } + + // Public entry point for a caller-side failure this bar never got to + // observe itself (e.g. the dispatch call that was supposed to create the + // run it would track failed outright) -- everything else here is + // internal to attributeRun/pollUntilDone/track. + renderError(message) { + this._renderError(message); + } + + _renderSteps(run, jobs) { + const steps = jobs.flatMap((j) => j.steps || []); + const total = steps.length; + const done = steps.filter((s) => s.status === "completed").length; + const pct = total > 0 ? Math.round((done / total) * 100) : run.status === "completed" ? 100 : 0; + + const failed = steps.find((s) => s.status === "completed" && s.conclusion === "failure"); + const running = steps.find((s) => s.status === "in_progress"); + + let summary; + let barClass = "status-bar-fill-progress"; + if (run.status === "completed") { + if (run.conclusion === "success") { + summary = "✓ Completed successfully"; + barClass = "status-bar-fill-success"; + } else if (failed) { + summary = `✗ Failed at "${failed.name}"`; + barClass = "status-bar-fill-failure"; + } else { + summary = `✗ ${run.conclusion || "did not complete"}`; + barClass = "status-bar-fill-failure"; + } + } else if (running) { + summary = `Running: ${running.name}`; + } else { + summary = "Queued…"; + } + + const stepRows = steps + .map((s) => { + const status = stepDisplayStatus(s); + return `
  • ${STEP_ICON[status] || "○"} ${s.name}
  • `; + }) + .join(""); + + this._shell(` +

    ${summary}

    +
    +

    Actions:

    + + View this run on GitHub → + `); + } + + // Polls workflowFile's run list until a run id appears that isn't in + // excludeIds (a Set, snapshotted by the caller immediately before its + // dispatch call). Resolves with that run's id once found (does not wait + // for it to finish -- see pollUntilDone for that), or null on timeout. + async attributeRun(workflowFile, excludeIds, { timeoutMs = 30000, intervalMs = 2500 } = {}) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + let runs; + try { + runs = await listWorkflowRuns(workflowFile, 15); + } catch (err) { + this._renderError(err.message); + return null; + } + const fresh = runs.find((r) => !excludeIds.has(r.id)); + if (fresh) { + this.runId = fresh.id; + return fresh.id; + } + await sleep(intervalMs); + } + this._renderError( + "Timed out waiting for the workflow to start. It may still be queued -- " + + `check the Actions tab.` + ); + return null; + } + + // Polls one run's jobs/steps, updating the status bar each time, until + // the run reports status "completed". Resolves with the final run object + // (its .conclusion is the thing to check), or null if runId is null + // (attributeRun already timed out and rendered its own error). + async pollUntilDone(runId, { intervalMs = 4000, timeoutMs = 10 * 60 * 1000 } = {}) { + if (runId == null) return null; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + let jobs; + try { + jobs = await getWorkflowRunJobs(runId); + } catch (err) { + this._renderError(err.message); + return null; + } + // The run's own status/conclusion isn't on the jobs response -- a job + // reliably mirrors it (this pipeline's workflows are always one job), + // and completed-with-no-jobs-yet only happens in the instant a run is + // first created, before its job list populates. + const job = jobs[0]; + const run = job + ? { id: runId, status: job.status, conclusion: job.conclusion } + : { id: runId, status: "queued", conclusion: null }; + this._renderSteps(run, jobs); + if (run.status === "completed") return run; + await sleep(intervalMs); + } + this._renderError( + "Timed out watching this run. It may still be in progress -- " + + `check it directly: ${RUN_URL(runId)}.` + ); + return null; + } + + // Convenience wrapper: attribute, then poll to completion. excludeIds + // must be snapshotted (listWorkflowRuns) by the caller *before* firing + // the dispatch this bar is meant to track. + async track(workflowFile, excludeIds) { + const runId = await this.attributeRun(workflowFile, excludeIds); + return this.pollUntilDone(runId); + } +} + +// Snapshot helper for the excludeIds a caller needs before dispatching -- +// just the current run ids for a workflow, as a Set. Kept here (rather than +// inlined at each call site) so "how far back to look" (perPage) stays in +// one place. +async function snapshotRunIds(workflowFile) { + const runs = await listWorkflowRuns(workflowFile, 15); + return new Set(runs.map((r) => r.id)); +} diff --git a/docs/publish.html b/docs/publish.html index 946d1b5..3d216cb 100644 --- a/docs/publish.html +++ b/docs/publish.html @@ -99,12 +99,14 @@

    4. Confirm

    +
    + diff --git a/docs/upload.html b/docs/upload.html index 69b8eec..9df9b56 100644 --- a/docs/upload.html +++ b/docs/upload.html @@ -120,6 +120,7 @@

    5. Confirm

    +
    @@ -127,6 +128,7 @@

    5. Confirm

    +