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 ? `