Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/publish-direct.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
25 changes: 25 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions docs/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions docs/js/github-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [];
}
43 changes: 38 additions & 5 deletions docs/js/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,34 +139,67 @@ 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 <a href="https://github.com/GenAI-Security-Project/translations/actions" target="_blank">Actions tab</a> for progress, ` +
`and each asset/locale's <code>release/</code> folder or the repo's Releases once done.`;
summary += `✓ Submitted: ${successes.join(", ")}. Progress for each is shown below.`;
}
if (failures.length) {
summary += `${summary ? "<br>" : ""}✗ Failed to submit: ${failures.join("; ")}`;
}
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", () => {
Expand Down
32 changes: 26 additions & 6 deletions docs/js/upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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. <code>bootstrap-asset</code> is now running — check the " +
`<a href="https://github.com/GenAI-Security-Project/translations/actions" target="_blank">Actions tab</a> ` +
"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 " +
`<a href="https://github.com/GenAI-Security-Project/translations/pulls" target="_blank">Pull Requests</a> ` +
"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;
}
}
Expand Down
Loading