OXYGENHUB

Release assets link to the receipt that built them 10708ab8f54d

An asset links to the passed Flow job whose artifact has the same BLAKE3,
one with a kept signed receipt first: the release page shows the run, job
and signed receipt, and the API gives built_by on each asset. Writers can
attach an artifact to a release from the run page.

closes #39
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 08:16 UTC
Signature
✔ signed by Andy McCall
Hash
10708ab8f54d3580c7d41a9c1b951ea89da8e12c295c595c6083aa492a80d885
Depends on
3f4b0ea6eabf 42fa95540468 93be9ce4653e a3c8288b0141 bf3d07541368 c79a1111dd56 cc19697f8871 d23464c0ac60
Depended on by
e4203e141e90 16ee5d19d220 97fc47fee9cf 076c7a7c86b1 107eaba52c9d c8684d44d549 5d90e0e045e2 417184c73f4e 4597750166e7 0b203bbf7cc3 6f52a0419331 554ccacfc123 745d225de344 df940d75cd3d
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
22 atoms, 0 tree operations, 10 files

Browse the tree at this change

modified crates/hub-api/src/lib.rs +17 −0
…
821 unchanged lines
822 822
    pub content_type: String,
823 823
    pub downloads: i64,
824 824
    pub url: String,
825 +
    /// The OxygenFlow run and job whose artifact has this content, if any.
826 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
827 +
    pub built_by: Option<AssetBuild>,
828 +
}
829 +
830 +
/// Where a release asset was built (hub spec §9): a job's artifact with the
831 +
/// same BLAKE3, and that job's signed receipt when one was kept.
832 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
833 +
pub struct AssetBuild {
834 +
    pub run: i64,
835 +
    pub job: String,
836 +
    pub artifact: String,
837 +
    pub run_url: String,
838 +
    /// The receipt attestation, hex; `None` when the receipt was not signed.
839 +
    pub receipt: Option<String>,
840 +
    pub receipt_url: Option<String>,
841 +
    pub signer: Option<String>,
825 842
}
826 843
827 844
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
…
74 unchanged lines
modified crates/hub/src/db/flow.rs +27 −0
…
481 unchanged lines
482 482
pub async fn artifacts(pool: &Pool, job_id: i64) -> sqlx::Result<Vec<Artifact>> {
483 483
    sqlx::query_as("SELECT * FROM flow_artifacts WHERE job_id = ? ORDER BY name").bind(job_id).fetch_all(pool).await
484 484
}
485 +
486 +
/// Where an artifact with given content came from: the run and job that
487 +
/// built it, and the job's signed receipt when one was kept.
488 +
#[derive(Clone, Debug, FromRow)]
489 +
pub struct Provenance {
490 +
    pub run_number: i64,
491 +
    pub job: String,
492 +
    pub name: String,
493 +
    pub receipt_attest: String,
494 +
    pub receipt_signer: String,
495 +
}
496 +
497 +
/// The build of the bytes whose BLAKE3 is `content` in repository
498 +
/// `repo_id`: a passed job, one with a kept signed receipt first, newest
499 +
/// run first.
500 +
pub async fn provenance(pool: &Pool, repo_id: i64, content: &str) -> sqlx::Result<Option<Provenance>> {
501 +
    sqlx::query_as(
502 +
        "SELECT r.number AS run_number, j.name AS job, a.name AS name, j.receipt_attest, j.receipt_signer \
503 +
         FROM flow_artifacts a JOIN flow_jobs j ON j.id = a.job_id JOIN flow_runs r ON r.id = j.run_id \
504 +
         WHERE r.repo_id = ? AND a.content = ? AND j.status = 'passed' \
505 +
         ORDER BY (j.receipt_attest != '') DESC, r.number DESC LIMIT 1",
506 +
    )
507 +
    .bind(repo_id)
508 +
    .bind(content)
509 +
    .fetch_optional(pool)
510 +
    .await
511 +
}
modified crates/hub/src/web/flow.rs +56 −1
…
251 unchanged lines
252 252
    can_rerun: bool,
253 253
    /// Where a live run stands among the runners' work.
254 254
    queue: Option<crate::flow::QueueInfo>,
255 +
    /// Releases a writer may attach this run's artifacts to.
256 +
    releases: Vec<db::releases::Release>,
255 257
}
256 258
257 259
/// Lines of a job's log a page shows before "the rest is loaded as it comes".
…
29 unchanged lines
287 289
        Some(crate::flow::queue_info(&q, row.id, run.id)).filter(|i| i.position.is_some())
288 290
    } else {
289 291
        None
292 +
    };
293 +
    let releases = if viewer.account().is_some() && role >= Role::Write && logs.iter().any(|l| !l.artifacts.is_empty()) {
294 +
        db::releases::list(&app.db, row.id).await.unwrap_or_default()
295 +
    } else {
296 +
        vec![]
290 297
    };
291 -
    Ok(Page(RunPage { shell, repo: RepoShell::new(&app, &row, "flow").with_role(role), run, graph, logs, duration, can_rerun, queue }))
298 +
    Ok(Page(RunPage {
299 +
        shell,
300 +
        repo: RepoShell::new(&app, &row, "flow").with_role(role),
301 +
        run,
302 +
        graph,
303 +
        logs,
304 +
        duration,
305 +
        can_rerun,
306 +
        queue,
307 +
        releases,
308 +
    }))
292 309
}
293 310
294 311
#[derive(Deserialize)]
…
279 unchanged lines
574 591
        bytes,
575 592
    )
576 593
        .into_response())
594 +
}
595 +
596 +
#[derive(Deserialize)]
597 +
pub struct AttachForm {
598 +
    _csrf: String,
599 +
    release: i64,
600 +
}
601 +
602 +
/// `POST /{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}/attach`: add
603 +
/// an artifact to a release as an asset, named after its path; the release
604 +
/// page then shows the run and receipt that built it.
605 +
pub async fn attach(
606 +
    State(app): State<Shared>,
607 +
    viewer: Viewer,
608 +
    Path((owner, name, id, job, artifact)): Path<(String, String, i64, String, String)>,
609 +
    Form(f): Form<AttachForm>,
610 +
) -> Result<Response, Error> {
611 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}"), &viewer);
612 +
    require(&viewer, &shell)?;
613 +
    csrf(&viewer, &f._csrf, &shell)?;
614 +
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
615 +
    let run = fdb::run(&app.db, row.id, id)
616 +
        .await
617 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
618 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
619 +
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
620 +
    let Some(j) = jobs.iter().find(|j| j.name == job) else { return Err(Error::not_found(shell)) };
621 +
    let arts = fdb::artifacts(&app.db, j.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
622 +
    let Some(a) = arts.into_iter().find(|a| a.name == artifact) else { return Err(Error::not_found(shell)) };
623 +
    let releases = db::releases::list(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
624 +
    let Some(rel) = releases.into_iter().find(|r| r.id == f.release) else { return Err(Error::not_found(shell)) };
625 +
    let Some(blob) = oxy_core::hash::ObjectRef::from_hex(&a.blob) else { return Err(Error::not_found(shell)) };
626 +
    let bytes = app.repos.read(&repo, move |r, _| Ok(r.read_blob(&blob)?)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
627 +
    let file = a.path.rsplit('/').next().unwrap_or(&a.name).to_string();
628 +
    super::releases::store_asset(&app, rel.id, &file, "application/octet-stream", &bytes)
629 +
        .await
630 +
        .map_err(|m| Error::bad(shell.clone(), m))?;
631 +
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", rel.tag)).into_response())
577 632
}
modified crates/hub/src/web/mod.rs +1 −0
…
151 unchanged lines
152 152
        .route("/{owner}/{name}/flow/at/{r}", get(flow::at))
153 153
        .route("/{owner}/{name}/receipts/{attest}", get(flow::receipt_file))
154 154
        .route("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}", get(flow::artifact_file))
155 +
        .route("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}/attach", post(flow::attach))
155 156
        .route("/{owner}/{name}/releases", get(releases::list))
156 157
        .route("/{owner}/{name}/releases/new", get(releases::new_form).post(releases::create))
157 158
        .route("/{owner}/{name}/releases/tag/{*tag}", get(releases::show))
…
431 unchanged lines
modified crates/hub/src/web/releases.rs +27 −1
…
23 unchanged lines
24 24
    pub heads: Vec<String>,
25 25
    pub signer: Option<String>,
26 26
    pub latest: bool,
27 +
    /// Where each asset was built, by asset id (hub spec §9).
28 +
    pub built: HashMap<i64, db::flow::Provenance>,
29 +
}
30 +
31 +
impl ReleaseView {
32 +
    pub fn built_by(&self, asset: &i64) -> Option<&db::flow::Provenance> {
33 +
        self.built.get(asset)
34 +
    }
27 35
}
28 36
29 37
async fn views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result<Vec<ReleaseView>, sqlx::Error> {
…
7 unchanged lines
37 45
    let mut out = Vec::new();
38 46
    for r in list {
39 47
        let assets = rdb::assets(&app.db, r.id).await?;
48 +
        let mut built = HashMap::new();
49 +
        for a in &assets {
50 +
            if let Some(p) = db::flow::provenance(&app.db, row.id, &a.hash).await? {
51 +
                built.insert(a.id, p);
52 +
            }
53 +
        }
40 54
        let (heads, signer) = tags.get(&r.tag).cloned().unwrap_or_default();
41 55
        let notes_html = super::issues::markdown(app, row, &r.tag, &r.notes).await;
42 -
        out.push(ReleaseView { latest: Some(r.id) == latest, r, notes_html, assets, heads, signer });
56 +
        out.push(ReleaseView { latest: Some(r.id) == latest, r, notes_html, assets, heads, signer, built });
43 57
    }
44 58
    Ok(out)
45 59
}
…
432 unchanged lines
478 492
                content_type: a.content_type.clone(),
479 493
                downloads: a.downloads,
480 494
                url: format!("{base}/asset/{}/{}", a.id, a.name),
495 +
                built_by: v.built_by(&a.id).map(|p| {
496 +
                    let signed = !p.receipt_attest.is_empty();
497 +
                    hub_api::AssetBuild {
498 +
                        run: p.run_number,
499 +
                        job: p.job.clone(),
500 +
                        artifact: p.name.clone(),
501 +
                        run_url: format!("{base}/flow/{}", p.run_number),
502 +
                        receipt: signed.then(|| p.receipt_attest.clone()),
503 +
                        receipt_url: signed.then(|| format!("{base}/receipts/{}", p.receipt_attest)),
504 +
                        signer: signed.then(|| p.receipt_signer.clone()),
505 +
                    }
506 +
                }),
481 507
            })
482 508
            .collect(),
483 509
        archives: ["tar.zst", "zip"].iter().map(|f| format!("{base}/archive?ref={}&format={f}", v.r.tag)).collect(),
…
8 unchanged lines
modified crates/hub/tests/flow.rs +46 −0
…
43 unchanged lines
44 44
    config.auth.push_tokens = vec!["site".into()];
45 45
    config.flow.runner_tokens = vec!["runner-secret".into()];
46 46
    config.flow.repos = vec!["andy/*".into()];
47 +
    config.registration.mode = "open".into();
47 48
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
48 49
    let port = listener.local_addr().unwrap().port();
49 50
    config.server.base_url = format!("http://127.0.0.1:{port}");
…
796 unchanged lines
846 847
    let (st, got) = get_bytes(&h.app, &link).await;
847 848
    assert_eq!((st, got == body), (StatusCode::OK, true));
848 849
    assert_eq!(get_bytes(&h.app, &format!("/andy/demo/flow/{}/artifacts/build/none", run.number)).await.0, StatusCode::NOT_FOUND);
850 +
851 +
    // A writer attaches the artifact to a release from the run page; the
852 +
    // release then names the run and job that built it, as does the API.
853 +
    let form = |f: String| {
854 +
        Request::post("/register").header(header::CONTENT_TYPE, "application/x-www-form-urlencoded").body(Body::from(f)).unwrap()
855 +
    };
856 +
    let res = oxygenhub::router(h.app.clone())
857 +
        .oneshot(form("username=andy&email=a%40example.com&password=correct-horse-battery".into()))
858 +
        .await
859 +
        .unwrap();
860 +
    let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string();
861 +
    let with_cookie = |method: &str, path: &str, body: Option<String>| {
862 +
        let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie.clone());
863 +
        if body.is_some() {
864 +
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
865 +
        }
866 +
        req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap()
867 +
    };
868 +
    let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap();
869 +
    db::releases::create(&h.app.db, row.id, "v1", "One", "", false, None).await.unwrap();
870 +
    let res = oxygenhub::router(h.app.clone()).oneshot(with_cookie("GET", &format!("/andy/demo/flow/{}", run.number), None)).await.unwrap();
871 +
    let page = String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned();
872 +
    let attach = format!("/andy/demo/flow/{}/artifacts/build/tool/attach", run.number);
873 +
    assert!(page.contains(&attach), "a writer is offered the release");
874 +
    let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len();
875 +
    let csrf = page[i..].split('"').next().unwrap().to_string();
876 +
    let release_id = db::releases::list(&h.app.db, row.id).await.unwrap()[0].id;
877 +
    let res = oxygenhub::router(h.app.clone())
878 +
        .oneshot(with_cookie("POST", &attach, Some(format!("_csrf={csrf}&release={release_id}"))))
879 +
        .await
880 +
        .unwrap();
881 +
    assert_eq!(res.status(), StatusCode::SEE_OTHER);
882 +
    let (_, page) = call(&h.app, "GET", "/andy/demo/releases/tag/v1", None, None).await;
883 +
    assert!(
884 +
        page.contains(">tool</a>") && page.contains(&format!("built by <a href=\"/andy/demo/flow/{0}\">run #{0}</a> build", run.number)),
885 +
        "{page}"
886 +
    );
887 +
    let (_, api) = call(&h.app, "GET", "/api/v1/repos/andy/demo/releases", None, None).await;
888 +
    let api: serde_json::Value = serde_json::from_str(&api).unwrap();
889 +
    let asset = &api[0]["assets"][0];
890 +
    assert_eq!(
891 +
        (asset["name"].as_str(), asset["built_by"]["run"].as_i64(), asset["built_by"]["job"].as_str()),
892 +
        (Some("tool"), Some(run.number), Some("build"))
893 +
    );
894 +
    assert!(asset["built_by"]["receipt"].is_null(), "that receipt was not signed");
849 895
}
modified docs/spec.md +2 −0
…
387 unchanged lines
388 388
389 389
Tags come from the repository (spec §10): a name, the state, and a signed attestation when the tagger signed. The tags page lists them with signature status. A release attaches to a tag: notes (Markdown, generated draft from the changes since the previous release), assets (uploaded files, content addressed, size limited by `asset_limit`), a pre-release flag, and a latest marker. Source archives (`.tar.zst`, `.zip`) of the tagged state are generated on demand and cached.
390 390
391 +
**Built by.** A release asset links to the build that produced it: the passed OxygenFlow job of this repository with an artifact of the same BLAKE3 (hub spec §13.2). A job with a kept signed receipt comes first, then the newest run. The release page shows the run, the job and the signed receipt with its signer, and the API gives the same as `built_by` on each asset. On a run page, a writer can attach an artifact to a release; it becomes an asset named after the artifact's path, so the link follows by content. A file uploaded by hand links the same way when its bytes match.
392 +
391 393
## 10. Notifications
392 394
393 395
Events: proposal opened, review requested, review submitted, comment on a thread you are in, mention (`@name`), landing, check failed on your proposal, issue assigned, policy proposal awaiting your signature, push refused. Each becomes an inbox item (`/notifications`, with unread count in the header) and, when the account chooses, an email through `lettre` with a per-account SMTP-independent unsubscribe link. Watching a repository subscribes to all its events; participating in a thread subscribes to it.
…
206 unchanged lines
modified templates/flow_run.html +2 −1
…
30 unchanged lines
31 31
</section>
32 32
{% for l in logs %}
33 33
<details class="panel flow-job st-{{ l.j.status }}" id="job-{{ l.j.id }}" data-job="{{ l.j.id }}"{% if l.j.status == "failed" || l.j.status == "error" || l.j.status == "running" %} open{% endif %}>
34 -
  <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}{% if !l.artifacts.is_empty() %} <span class="small mute">· {% for a in l.artifacts %}<a href="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}" title="{{ a.path }}, blob {{ a.blob }}">{{ a.name }}</a> ({{ a.size|bytes }}){% if !loop.last %}, {% endif %}{% endfor %}</span>{% endif %}</summary>
34 +
  <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}{% if !l.artifacts.is_empty() %} <span class="small mute">· {{ l.artifacts.len()|plural("artifact", "artifacts") }}</span>{% endif %}</summary>
35 35
  {% if !l.j.error.is_empty() %}<div class="strip bad">{{ l.j.error }}</div>{% endif %}
36 +
  {% if !l.artifacts.is_empty() %}<ul class="flow-artifacts small">{% for a in l.artifacts %}<li><a href="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}">{{ a.name }}</a> <span class="mute">{{ a.path }} · {{ a.size|bytes }} · blob <code title="{{ a.blob }}">{{ a.blob|short }}</code></span>{% if !releases.is_empty() %} <form class="tiny inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}/attach"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="release" aria-label="Release to attach {{ a.name }} to">{% for r in releases %}<option value="{{ r.id }}">{{ r.tag }}</option>{% endfor %}</select> <button>Attach to release</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %}
36 37
  <pre class="flow-log" data-log data-seq="{{ l.last_seq }}">{% for line in l.lines %}<span class="ln s-{{ line.stream }}"><span class="t">{{ line.t / 1000 }}.{{ "{:01}"|format(line.t % 1000 / 100) }}s</span>{{ line.text }}</span>
37 38
{% endfor %}</pre>
38 39
  {% if l.lines.is_empty() && l.j.status == "queued" %}<p class="small mute pad">Waiting for a runner{% if !l.j.depends_on().is_empty() %} and for {{ l.j.depends_on().join(", ") }}{% endif %}.</p>{% endif %}
…
5 unchanged lines
modified templates/releases.html +1 −1
…
16 unchanged lines
17 17
  {% if !v.r.notes.is_empty() %}<article class="doc">{{ v.notes_html|safe }}</article>{% endif %}
18 18
  <h3>Assets</h3>
19 19
  <ul class="plain">
20 -
    {% for a in v.assets %}<li><a href="{{ repo.url }}/asset/{{ a.id }}/{{ a.name }}">{{ a.name }}</a> <span class="small mute">{{ a.size|bytes }} · blake3 <code title="{{ a.hash }}">{{ a.hash|short }}</code> · {{ a.downloads }} downloads</span>
20 +
    {% for a in v.assets %}<li><a href="{{ repo.url }}/asset/{{ a.id }}/{{ a.name }}">{{ a.name }}</a> <span class="small mute">{{ a.size|bytes }} · blake3 <code title="{{ a.hash }}">{{ a.hash|short }}</code> · {{ a.downloads }} downloads</span>{% if let Some(p) = v.built_by(a.id) %} <span class="small" data-built>· built by <a href="{{ repo.url }}/flow/{{ p.run_number }}">run #{{ p.run_number }}</a> {{ p.job }}{% if p.receipt_attest.is_empty() %} <span class="mute">(unsigned receipt)</span>{% else %} · <a href="{{ repo.url }}/receipts/{{ p.receipt_attest }}" title="Check it with oxy receipt verify">signed receipt</a> by {{ p.receipt_signer }}{% endif %}</span>{% endif %}
21 21
      {% if one && repo.can(crate::auth::Access::Write) %}<form class="tiny" method="post" action="{{ repo.url }}/releases/edit/{{ v.r.id }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="asset" value="{{ a.id }}"><button name="action" value="delete_asset">delete</button></form>{% endif %}</li>{% endfor %}
22 22
    <li><a href="{{ repo.url }}/archive?ref={{ v.r.tag }}&format=tar.zst">Source ({{ v.r.tag }}.tar.zst)</a></li>
23 23
    <li><a href="{{ repo.url }}/archive?ref={{ v.r.tag }}&format=zip">Source ({{ v.r.tag }}.zip)</a></li>
…
18 unchanged lines
modified templates/style.css +3 −0
…
440 unchanged lines
441 441
details.flow-job .pad { padding: 0 12px 10px; margin: 0; }
442 442
pre.flow-log { margin: 0; padding: 8px 0; max-height: 70vh; overflow: auto; border-top: 1px solid var(--line); background: var(--soft); font-size: 12.5px; line-height: 1.5; }
443 443
pre.flow-log:empty { display: none; }
444 +
ul.flow-artifacts { list-style: none; margin: 0; padding: 6px 12px; border-top: 1px solid var(--line); }
445 +
ul.flow-artifacts li { padding: 2px 0; overflow-wrap: anywhere; }
446 +
ul.flow-artifacts select { font-size: 11px; }
444 447
pre.flow-log .ln { display: block; padding: 0 12px; white-space: pre-wrap; word-break: break-word; }
445 448
pre.flow-log .t { display: inline-block; min-width: 5.5em; color: var(--mute); user-select: none; }
446 449
pre.flow-log .s-err { color: color-mix(in srgb, var(--bad) 75%, var(--ink)); }
…
75 unchanged lines