Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c2efced
Add design doc for CI test log grouping and failure surfacing
claude Jul 20, 2026
9d98634
docs: cross-reference the julia-engine binary-mode gate from the grou…
claude Jul 20, 2026
4e88441
docs: resolve design-review findings on the CI log grouping plan
claude Jul 20, 2026
3a78086
Add GitHub Actions failure-reporting helpers to github.ts
cderv Jul 20, 2026
415c10f
Surface CI test failures as annotations and a step summary
cderv Jul 20, 2026
5060364
docs: record the adopted Phase 1 summary-layout deviation in the design
claude Jul 20, 2026
3d16a8d
Emit per-test-file ::group:: markers on the default CI test path
cderv Jul 20, 2026
ae645e8
Add a log checker guarding the CI grouping invariants
cderv Jul 20, 2026
00f703f
docs: mark Phases 1-2 implemented; record Windows log-capture gotcha
claude Jul 20, 2026
3796a2b
Apply design-review fixes: group closure on early failures, step-wide…
claude Jul 20, 2026
c7746dd
Fix CI-environment leakage in reporting/grouping unit tests (null sen…
claude Jul 20, 2026
fc4ef5c
docs: correct log-grouping design-doc motivation and citations
cderv Jul 21, 2026
4930283
Name each step-summary detail block after its failing test
claude Jul 22, 2026
1799a1f
Open the per-file CI test group at registration time
cderv Jul 22, 2026
a7056c8
Document the Phase 2.1 registration-time group open
cderv Jul 22, 2026
0476b33
Cluster, label, and trim CI test-failure step summaries
cderv Jul 22, 2026
bfe01ca
Make the version shortcode robust to string-typed quarto.version
cderv Jul 23, 2026
345fc80
Stamp built test dists with the plain version, not a +test marker
cderv Jul 23, 2026
777822a
Merge remote-tracking branch 'upstream/test/smoke-tests-built-version…
cderv Jul 23, 2026
2d41c2d
Merge test/smoke-tests-built-version into gha-log-grouping
cderv Jul 24, 2026
ca03a46
Merge test/smoke-tests-built-version into gha-log-grouping
cderv Jul 24, 2026
da7058d
Merge test/smoke-tests-built-version into gha-log-grouping
cderv Jul 24, 2026
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
8 changes: 2 additions & 6 deletions .github/workflows/test-smokes-built.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,10 @@ jobs:
shell: bash
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

- name: Compute version marker
- name: Compute build version
id: version
shell: bash
# BUILD METADATA version marker (+test.<date>): semver-valid and
# range-transparent for every 'quarto-required' gate, while still
# distinguishable from the dev sentinel 99.9.9. NEVER use a '-suffix'
# (prereleases fail plain ranges) or a 4th dot component (not semver).
run: echo "version=$(cat version.txt)+test.$(date +%Y%m%d)" >> "$GITHUB_OUTPUT"
run: echo "version=$(cat version.txt)" >> "$GITHUB_OUTPUT"

- name: Build dist tarball
uses: ./.github/actions/build-dist-tarball
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/test-smokes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,9 @@ jobs:
env:
# Useful as TinyTeX latest release is checked in run-test.sh
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# This loop owns the per-file ::group:: and ::error:: markers, so the
# test harness must not also emit annotations for this step.
QUARTO_TESTS_GHA_ORCHESTRATED: "1"
run: |
haserror=0
failed_tests=()
Expand Down Expand Up @@ -514,6 +517,9 @@ jobs:
env:
# Useful as TinyTeX latest release is checked in run-test.sh
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# This loop owns the per-file ::group:: and ::error:: markers, so the
# test harness must not also emit annotations for this step.
QUARTO_TESTS_GHA_ORCHESTRATED: "1"
run: |
$haserror=$false
$failed_tests=@()
Expand Down
475 changes: 475 additions & 0 deletions dev-docs/ci-test-log-grouping-design.md

Large diffs are not rendered by default.

21 changes: 14 additions & 7 deletions llm-docs/built-version-testing-architecture.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
main_commit: 2e6695811
analyzed_date: 2026-07-20
analyzed_date: 2026-07-23
key_files:
- tests/quarto-cmd.ts
- tests/test.ts
Expand Down Expand Up @@ -202,14 +202,21 @@ OS leg is gated on its artifact actually existing in the resolved run.
one atomic nightly build-and-test signal — then the inversion is the
principled consolidation, done as a deliberate follow-up.

### D2. Version marker: semver *build metadata* (`X.Y.Z+test.YYYYMMDD`)

Built test dists are stamped `$(cat version.txt)+test.$(date +%Y%m%d)`.
Never a `-suffix` (prerelease versions fail every plain `>=X.Y`
### D2. Version stamp: the plain three-component version (`X.Y.Z`)

Built test dists are stamped `$(cat version.txt)` — a plain `X.Y.Z`. An
earlier `+test.YYYYMMDD` build-metadata marker was dropped: though valid
semver and range-transparent, build metadata is NOT parseable by pandoc's
`Version` type (used by the `version` shortcode), so `quarto.version` fell
back to a raw string and version-dependent smoke docs (issue-12006, 13913,
which assert a three-component version) failed. A plain version is
byte-representative of a real release, still range-transparent for every
`quarto-required` gate, and still distinguishable from the `99.9.9` dev
sentinel. Never a `-suffix` (prerelease versions fail every plain `>=X.Y`
`quarto-required` range — the vendored semver has no `includePrerelease`
anywhere) and never a 4th dot component (not semver; the vendored
`deno.land/x/[email protected]` throws). Build metadata is range-transparent for
every gate while still distinguishable from the `99.9.9` dev sentinel.
`deno.land/x/[email protected]` throws). Trial builds are identified by run
context (workflow + ref + sha), not the version string.

### D3. Dist outside the checkout + `99.9.9` sentinel refusal

Expand Down
2 changes: 1 addition & 1 deletion src/resources/extensions/quarto/version/version.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
return {
['version'] = function(args, kwargs, meta)
return table.concat(quarto.version, '.')
return tostring(quarto.version)
end
}
279 changes: 279 additions & 0 deletions src/tools/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,285 @@ export async function group<T>(
return await withGroupAsync(title, fn);
}

// GitHub Actions failure reporting (used by the test harness — see
// dev-docs/ci-test-log-grouping-design.md). All of these are pure helpers or
// gate on GITHUB_ACTIONS so callers stay byte-identical off CI.

// Strip ANSI escape sequences. Annotation messages and step-summary content
// do NOT render ANSI (only group bodies in the log viewer do), so color codes
// must be removed before embedding captured output in either.
// deno-lint-ignore no-control-regex
const kAnsiPattern = /\x1b\[[0-9;?]*[A-Za-z]/g;
export function stripAnsi(s: string): string {
return s.replace(kAnsiPattern, "");
}

// The harness emits per-test ::error annotations only when it owns the
// workflow step: on CI and when no outer orchestrator has claimed the step
// via QUARTO_TESTS_GHA_ORCHESTRATED (the bucket-loop YAML sets it and emits
// its own per-file ::error). Arguments default to the live environment;
// unit tests pass them explicitly to avoid mutating process-global env.
// `orchestrated` semantics: omitted → read QUARTO_TESTS_GHA_ORCHESTRATED
// from the live environment; `null` → treat as unset. Tests must pass `null`
// (never an explicit `undefined`, which triggers the default parameter and
// reads the real env — inside a CI bucket step that variable IS set, so the
// test would flip depending on where it runs).
export function harnessOwnsStep(
githubActions: boolean = isGitHubActions(),
orchestrated?: string | null,
): boolean {
const o = orchestrated === undefined
? Deno.env.get("QUARTO_TESTS_GHA_ORCHESTRATED")
: orchestrated;
return githubActions && !o;
}

// GitHub caps annotations at 10 ::error per workflow STEP; excess is silently
// dropped. One `deno test` process is the step in the harness-owned path, BUT
// Deno instantiates each test file's module graph separately (verified on the
// pinned 2.7.14: module state does not carry across test files, and `unload`
// fires once per file), so a module-level counter would be a per-FILE budget.
// The count is therefore coordinated through a sidecar counter file derived
// from GITHUB_STEP_SUMMARY — unique per step and in a runner-writable
// directory. No locking: without `deno test --parallel`, test files run
// strictly sequentially. With no counter path (not on CI), state falls back
// to instance-local, which unit tests also use via injection.
export function defaultAnnotationCounterPath(): string | undefined {
const summary = Deno.env.get("GITHUB_STEP_SUMMARY");
return summary ? `${summary}.qt-annotation-count` : undefined;
}

export interface AnnotationDecision {
// step-wide 1-based count of this failure (every CI failure gets one, used
// to build its navigation label; see failureLabel)
ordinal: number;
// emit the per-test ::error for this failure
emitAnnotation: boolean;
// this failure is the first one past the cap: emit the single aggregate
// ::error (the 10th and last annotation for the step) instead
emitAggregate: boolean;
}

// `counterPath` semantics: omitted → the env-derived per-step sidecar file;
// `null` → instance-local state, guaranteed to touch no file. The null
// sentinel exists because passing `undefined` explicitly triggers the
// default parameter (JS semantics) — in CI that silently pointed unit tests
// at the REAL step counter, found by trial run cderv/quarto-cli#29767179626.
export class AnnotationBudget {
private localCount = 0;
private readonly counterPath: string | null;
constructor(
private readonly max = 9,
counterPath?: string | null,
) {
this.counterPath = counterPath === undefined
? defaultAnnotationCounterPath() ?? null
: counterPath;
}

private readCount(): number {
if (this.counterPath === null) return this.localCount;
try {
return parseInt(Deno.readTextFileSync(this.counterPath), 10) || 0;
} catch {
return 0;
}
}

private writeCount(n: number): void {
if (this.counterPath === null) {
this.localCount = n;
return;
}
Deno.writeTextFileSync(this.counterPath, String(n));
}

// Record one failure and decide what to emit for it. Returns the step-wide
// ordinal (the running count, used to build the failure's navigation label
// — every failure gets one, uncapped) alongside the emit decisions. The
// aggregate fires exactly when the count first crosses the cap — no
// end-of-run hook exists that spans test files, so it must be emitted inline
// by the failure that crosses the line; later failures emit nothing.
recordFailure(): AnnotationDecision {
const count = this.readCount() + 1;
this.writeCount(count);
return {
ordinal: count,
emitAnnotation: count <= this.max,
emitAggregate: count === this.max + 1,
};
}
}

// GITHUB_STEP_SUMMARY is 1 MiB per step and content just under the limit can
// be silently dropped (actions/runner#4337). Stay at ~half so bucket mode —
// many processes appending to the same file — has headroom; the file size
// itself is the cross-process coordinator.
export const kStepSummaryBudgetBytes = 512 * 1024;

// Append markdown to the GitHub Actions step summary. No-op when the file is
// unset (local runs, or steps without a summary). `path` semantics: omitted →
// $GITHUB_STEP_SUMMARY; `null` (or "") → guaranteed no-op. Tests must pass
// `null` or a temp path, never an explicit `undefined` — that triggers the
// default parameter and, on CI, writes to the REAL step summary (found by
// trial run cderv/quarto-cli#29767179626).
export function stepSummary(
markdown: string,
path?: string | null,
): void {
const p = path === undefined ? Deno.env.get("GITHUB_STEP_SUMMARY") : path;
if (!p) return;
Deno.writeTextFileSync(p, markdown, { append: true });
}

// Current size of the step-summary file (0 when unset/missing). Callers
// compare against kStepSummaryBudgetBytes to decide whether to degrade.
// Same `path` semantics as stepSummary.
export function stepSummarySize(
path?: string | null,
): number {
const p = path === undefined ? Deno.env.get("GITHUB_STEP_SUMMARY") : path;
if (!p) return 0;
try {
return Deno.statSync(p).size;
} catch {
return 0;
}
}

function htmlEscape(s: string): string {
return s
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}

// A table cell must not contain a raw `|` (column separator) or newline (row
// terminator); HTML-escape the rest so angle brackets in test names render.
function summaryCell(s: string): string {
return htmlEscape(s).replaceAll("|", "\\|").replace(/\r?\n/g, " ");
}

function formatDuration(ms: number): string {
return `${(ms / 1000).toFixed(2)}s`;
}

// A failure's navigation label: the step-wide ordinal prefixed with the
// runner OS (RUNNER_OS: "Linux"/"Windows"/"macOS"; anything else → X). The
// run summary page concatenates every job's summary, so the prefix keeps the
// label unambiguous across jobs — it is the Ctrl+F target that ties a table
// row to its detail block (step-summary heading anchors do not resolve, so
// there is no link; the shared ASCII label is the navigation).
export function failureLabel(ordinal: number, runnerOs: string): string {
const os = runnerOs.toLowerCase();
const prefix = os.startsWith("linux")
? "L"
: os.startsWith("windows")
? "W"
: os.startsWith("macos")
? "M"
: "X";
return `${prefix}-F${ordinal}`;
}

// The clustering key for a failure: the first three non-empty lines of the
// ANSI-stripped excerpt. Three, not one, because the first line alone is a
// generic banner for many failure kinds and would over-cluster.
export function excerptSignature(excerpt: string): string {
return stripAnsi(excerpt)
.split("\n")
.filter((line) => line.trim().length > 0)
.slice(0, 3)
.join("\n");
}

export function summaryTableHeader(): string {
return "| # | Test file | Test | Duration |\n| :-- | :-- | :-- | --: |\n";
}

export function summaryTableRow(
label: string,
file: string,
name: string,
durationMs: number,
): string {
return `| ${label} | \`${file}\` | ${summaryCell(name)} | ${
formatDuration(durationMs)
} |\n`;
}

// Degraded row emitted once the summary file is over budget: the failure is
// still recorded by name (the complete record), just without duration/output.
// The label column renders the same plain text as a full row.
export function summaryTableRowNameOnly(
label: string,
file: string,
name: string,
): string {
return `| ${label} | \`${file}\` | ${summaryCell(name)} | |\n`;
}

export interface ClusterMember {
label: string;
file: string;
testName: string;
repro: string;
}

export interface FailureCluster {
label: string;
members: ClusterMember[];
excerpt: string;
}

// Expandable output block for one CLUSTER of same-signature failures. Preceded
// by a label-only `#### L-F7` heading (the second Ctrl+F hit). The <summary>
// label names the first failing test (file + test name); with >1 member it
// also carries the member count and lists every member (label + file + repro).
// One shared excerpt (the first member's) renders inside a <pre> with
// HTML-escaped content so backticks/pipes/angle brackets in captured output
// can't break out. GFM ends a table at the first non-row line, so these blocks
// are flushed after all table rows, never interleaved between them.
export function summaryClusterBlock(cluster: FailureCluster): string {
const first = cluster.members[0];
const n = cluster.members.length;
const count = n > 1 ? ` (${n} tests)` : "";
const summaryLabel = `<code>${htmlEscape(first.file)}</code> — ${
htmlEscape(first.testName).replace(/\r?\n/g, " ")
}${count}`;
// With more than one member, list every clustered failure (label + file +
// repro) so the reader can reach each one; the shared excerpt is shown once.
let memberList = "";
if (n > 1) {
memberList = "\n" + cluster.members
.map((m) =>
`- ${m.label} · <code>${htmlEscape(m.file)}</code> (<code>${
htmlEscape(m.repro)
}</code>)`
)
.join("\n") + "\n";
}
const body = htmlEscape(`${first.repro}\n\n${cluster.excerpt}`);
return `\n#### ${cluster.label}\n\n<details><summary>${summaryLabel}</summary>\n${memberList}\n<pre>\n${body}\n</pre>\n</details>\n\n`;
}

// Trimmed annotation message: the repro, a blank line, the first `maxLines`
// non-empty excerpt lines, an ellipsis, then a pointer at the step-summary
// entry (by label). The full repro+excerpt already lives in the summary on the
// same page, so the annotation only needs enough to identify the failure.
export function annotationBody(
repro: string,
excerpt: string,
label: string,
maxLines = 5,
): string {
const lines = stripAnsi(excerpt)
.split("\n")
.filter((line) => line.trim().length > 0)
.slice(0, maxLines);
return `${repro}\n\n${lines.join("\n")}\n…\nFull output: step summary → ${label}`;
}

// GitHub API

// A Github Release for a Github Repo
Expand Down
2 changes: 1 addition & 1 deletion tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ To run in binary mode locally:
```bash
# 1. Build a distribution (after ./configure.sh)
cd package/src
./quarto-bld prepare-dist --set-version "$(cat ../../version.txt)+test.$(date +%Y%m%d)"
./quarto-bld prepare-dist --set-version "$(cat ../../version.txt)"
cd ../..

# 2. Copy the built dist OUTSIDE the git checkout. An in-repo quarto
Expand Down
Loading