diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml
index c619a16f..313059d2 100644
--- a/.github/workflows/csharp.yml
+++ b/.github/workflows/csharp.yml
@@ -150,6 +150,8 @@ jobs:
id: version-check
run: |
set -euo pipefail
+ # shellcheck source=scripts/ci/registry-probe.sh
+ . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh"
PACKAGE_VERSION=$(sed -n 's|.*\(.*\).*|\1|p' "$PROJECT_PATH")
if [ -z "$PACKAGE_VERSION" ]; then
echo "::error::Could not read from $PROJECT_PATH"
@@ -161,7 +163,7 @@ jobs:
echo "Package: $PACKAGE_ID@$PACKAGE_VERSION"
# Flat-container URLs are lowercase-only.
- if curl -fsS "https://api.nuget.org/v3-flatcontainer/$PACKAGE_ID_LOWER/$PACKAGE_VERSION/$PACKAGE_ID_LOWER.nuspec" > /dev/null 2>&1; then
+ if probe_registry "https://api.nuget.org/v3-flatcontainer/$PACKAGE_ID_LOWER/$PACKAGE_VERSION/$PACKAGE_ID_LOWER.nuspec"; then
echo "Version $PACKAGE_VERSION already exists on NuGet.org"
echo "should_publish=false" >> "$GITHUB_OUTPUT"
else
@@ -207,18 +209,15 @@ jobs:
PACKAGE_VERSION: ${{ steps.version-check.outputs.version }}
run: |
set -euo pipefail
+ # shellcheck source=scripts/ci/registry-probe.sh
+ . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh"
# NuGet.org indexes asynchronously; poll rather than assume success.
- for attempt in $(seq 1 20); do
- if curl -fsS "https://api.nuget.org/v3-flatcontainer/${PACKAGE_ID_LOWER}/index.json" \
- | grep -q "\"${PACKAGE_VERSION}\""; then
- echo "Verified ${PACKAGE_ID}@${PACKAGE_VERSION} on NuGet.org (attempt ${attempt})"
- exit 0
- fi
- echo "Not indexed yet, retrying in 30s (attempt ${attempt}/20)"
- sleep 30
- done
- echo "::error::${PACKAGE_ID}@${PACKAGE_VERSION} did not appear on NuGet.org within 10 minutes"
- exit 1
+ # The flat container exposes one document per package, so the body
+ # has to be matched -- a 200 only proves the package exists at all.
+ wait_for_registry_match \
+ "${PACKAGE_ID}@${PACKAGE_VERSION} on NuGet.org" \
+ "https://api.nuget.org/v3-flatcontainer/${PACKAGE_ID_LOWER}/index.json" \
+ "\"${PACKAGE_VERSION}\"" 20 30
generatePdfWithCode:
runs-on: ubuntu-latest
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index bd959c68..f5306f84 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -212,15 +212,10 @@ jobs:
PACKAGE_VERSION: ${{ steps.version-check.outputs.version }}
run: |
set -euo pipefail
+ # shellcheck source=scripts/ci/registry-probe.sh
+ . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh"
# The proxy only fetches a version once someone asks for it, so this
# also warms it for the first consumer.
- for attempt in $(seq 1 20); do
- if curl -fsS "https://proxy.golang.org/${MODULE}/@v/v${PACKAGE_VERSION}.info" >/dev/null 2>&1; then
- echo "Verified ${MODULE}@v${PACKAGE_VERSION} on proxy.golang.org (attempt ${attempt})"
- exit 0
- fi
- echo "Not on the proxy yet, retrying in 15s (attempt ${attempt}/20)"
- sleep 15
- done
- echo "::error::${MODULE}@v${PACKAGE_VERSION} did not appear on proxy.golang.org within 5 minutes"
- exit 1
+ wait_for_registry \
+ "${MODULE}@v${PACKAGE_VERSION} on proxy.golang.org" \
+ "https://proxy.golang.org/${MODULE}/@v/v${PACKAGE_VERSION}.info" 20 15
diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml
index 98a4c95b..e1e7b77a 100644
--- a/.github/workflows/java.yml
+++ b/.github/workflows/java.yml
@@ -187,6 +187,8 @@ jobs:
id: version-check
run: |
set -euo pipefail
+ # shellcheck source=scripts/ci/registry-probe.sh
+ . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh"
PACKAGE_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
PACKAGE_GROUP=$(mvn help:evaluate -Dexpression=project.groupId -q -DforceStdout)
PACKAGE_ARTIFACT=$(mvn help:evaluate -Dexpression=project.artifactId -q -DforceStdout)
@@ -198,7 +200,7 @@ jobs:
echo "Package: $PACKAGE_GROUP:$PACKAGE_ARTIFACT:$PACKAGE_VERSION"
URL="https://repo1.maven.org/maven2/$(echo "$PACKAGE_GROUP" | tr '.' '/')/$PACKAGE_ARTIFACT/$PACKAGE_VERSION/"
- if curl --head --silent --fail "$URL" > /dev/null 2>&1; then
+ if probe_registry "$URL"; then
echo "Version $PACKAGE_VERSION already exists on Maven Central"
echo "should_publish=false" >> "$GITHUB_OUTPUT"
else
@@ -235,18 +237,13 @@ jobs:
PACKAGE_VERSION: ${{ steps.version-check.outputs.version }}
run: |
set -euo pipefail
+ # shellcheck source=scripts/ci/registry-probe.sh
+ . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh"
GROUP_PATH=$(echo "$PACKAGE_GROUP" | tr '.' '/')
- URL="https://repo1.maven.org/maven2/${GROUP_PATH}/${PACKAGE_ARTIFACT}/${PACKAGE_VERSION}/${PACKAGE_ARTIFACT}-${PACKAGE_VERSION}.pom"
- for attempt in $(seq 1 30); do
- if curl --head --silent --fail "$URL" > /dev/null 2>&1; then
- echo "Verified ${PACKAGE_GROUP}:${PACKAGE_ARTIFACT}:${PACKAGE_VERSION} on Maven Central (attempt ${attempt})"
- exit 0
- fi
- echo "Not synced yet, retrying in 30s (attempt ${attempt}/30)"
- sleep 30
- done
- echo "::error::${PACKAGE_GROUP}:${PACKAGE_ARTIFACT}:${PACKAGE_VERSION} did not appear on Maven Central within 15 minutes"
- exit 1
+ wait_for_registry \
+ "${PACKAGE_GROUP}:${PACKAGE_ARTIFACT}:${PACKAGE_VERSION} on Maven Central" \
+ "https://repo1.maven.org/maven2/${GROUP_PATH}/${PACKAGE_ARTIFACT}/${PACKAGE_VERSION}/${PACKAGE_ARTIFACT}-${PACKAGE_VERSION}.pom" \
+ 30 30
publishRelease:
runs-on: ubuntu-latest
diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml
index dfc15004..8b95d1e2 100644
--- a/.github/workflows/js.yml
+++ b/.github/workflows/js.yml
@@ -237,16 +237,11 @@ jobs:
PACKAGE_VERSION: ${{ steps.version-check.outputs.version }}
run: |
set -euo pipefail
- for attempt in $(seq 1 10); do
- if curl -fsS "https://registry.npmjs.org/${PACKAGE_NAME}/${PACKAGE_VERSION}" >/dev/null 2>&1; then
- echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on npm (attempt ${attempt})"
- exit 0
- fi
- echo "Not visible yet, retrying in 15s (attempt ${attempt}/10)"
- sleep 15
- done
- echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on the npm registry within 2.5 minutes"
- exit 1
+ # shellcheck source=scripts/ci/registry-probe.sh
+ . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh"
+ wait_for_registry \
+ "${PACKAGE_NAME}@${PACKAGE_VERSION} on npm" \
+ "https://registry.npmjs.org/${PACKAGE_NAME}/${PACKAGE_VERSION}" 10 15
publishRelease:
runs-on: ubuntu-latest
diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
index a6502545..e870b401 100644
--- a/.github/workflows/php.yml
+++ b/.github/workflows/php.yml
@@ -150,6 +150,8 @@ jobs:
id: version-check
run: |
set -euo pipefail
+ # shellcheck source=scripts/ci/registry-probe.sh
+ . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh"
PACKAGE_NAME=$(php -r 'echo json_decode(file_get_contents("composer.json"))->name;')
PACKAGE_VERSION=$(php -r 'echo json_decode(file_get_contents("composer.json"))->version;')
echo "name=$PACKAGE_NAME" >> "$GITHUB_OUTPUT"
@@ -159,7 +161,7 @@ jobs:
# Distinguish "package is not on Packagist at all" from "this version
# is not there yet". The old check collapsed both into should_publish
# and then the update call quietly did nothing.
- if ! METADATA=$(curl -fsS "https://repo.packagist.org/p2/${PACKAGE_NAME}.json" 2>/dev/null); then
+ if ! METADATA=$(fetch_registry "https://repo.packagist.org/p2/${PACKAGE_NAME}.json"); then
echo "package_known=false" >> "$GITHUB_OUTPUT"
echo "should_publish=true" >> "$GITHUB_OUTPUT"
exit 0
@@ -217,18 +219,14 @@ jobs:
PACKAGE_VERSION: ${{ steps.version-check.outputs.version }}
run: |
set -euo pipefail
- # Packagist crawls asynchronously after the update call returns 202.
- for attempt in $(seq 1 20); do
- if curl -fsS "https://repo.packagist.org/p2/${PACKAGE_NAME}.json" \
- | grep -q "\"version\":\"${PACKAGE_VERSION}\""; then
- echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on Packagist (attempt ${attempt})"
- exit 0
- fi
- echo "Not crawled yet, retrying in 30s (attempt ${attempt}/20)"
- sleep 30
- done
- echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on Packagist within 10 minutes"
- exit 1
+ # shellcheck source=scripts/ci/registry-probe.sh
+ . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh"
+ # Packagist crawls asynchronously after the update call returns 202,
+ # and serves every version from one document, so match the body.
+ wait_for_registry_match \
+ "${PACKAGE_NAME}@${PACKAGE_VERSION} on Packagist" \
+ "https://repo.packagist.org/p2/${PACKAGE_NAME}.json" \
+ "\"version\":\"${PACKAGE_VERSION}\"" 20 30
publishRelease:
runs-on: ubuntu-latest
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
index 5d04d2ca..c16db495 100644
--- a/.github/workflows/python.yml
+++ b/.github/workflows/python.yml
@@ -157,6 +157,8 @@ jobs:
id: version-check
run: |
set -euo pipefail
+ # shellcheck source=scripts/ci/registry-probe.sh
+ . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh"
read -r PACKAGE_NAME PACKAGE_VERSION <<<"$(python -c "
import tomllib
with open('pyproject.toml','rb') as f:
@@ -170,7 +172,7 @@ jobs:
# The JSON API is an exact lookup. The previous `pip index versions |
# grep` matched substrings, so 0.1.0 looked published once 0.1.0.1
# existed.
- if curl -fsS "https://pypi.org/pypi/${PACKAGE_NAME}/${PACKAGE_VERSION}/json" >/dev/null 2>&1; then
+ if probe_registry "https://pypi.org/pypi/${PACKAGE_NAME}/${PACKAGE_VERSION}/json"; then
echo "Version $PACKAGE_VERSION already exists on PyPI"
echo "should_publish=false" >> "$GITHUB_OUTPUT"
else
@@ -179,12 +181,21 @@ jobs:
fi
# Trusted publishing when configured; an empty `password` makes the
# action fall back to OIDC, and PYPI_TOKEN is only the bootstrap.
+ #
+ # The action defaults `attestations` to true, but attestations require
+ # trusted publishing, so passing a password made every release log
+ # "the attestations input is ignored" as a warning. The two settings are
+ # now driven by the same opt-in variable and can no longer contradict
+ # each other. Register the publisher at
+ # https://pypi.org/manage/project/links-notation/settings/publishing/
+ # and set the `PYPI_TRUSTED_PUBLISHING` repository variable to `true`.
- name: Publish to PyPI
if: steps.version-check.outputs.should_publish == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: python/dist
- password: ${{ secrets.PYPI_TOKEN }}
+ password: ${{ vars.PYPI_TRUSTED_PUBLISHING == 'true' && '' || secrets.PYPI_TOKEN }}
+ attestations: ${{ vars.PYPI_TRUSTED_PUBLISHING == 'true' }}
- name: Record publish outcome
id: publish
run: |
@@ -201,16 +212,11 @@ jobs:
PACKAGE_VERSION: ${{ steps.version-check.outputs.version }}
run: |
set -euo pipefail
- for attempt in $(seq 1 10); do
- if curl -fsS "https://pypi.org/pypi/${PACKAGE_NAME}/${PACKAGE_VERSION}/json" >/dev/null 2>&1; then
- echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on PyPI (attempt ${attempt})"
- exit 0
- fi
- echo "Not visible yet, retrying in 15s (attempt ${attempt}/10)"
- sleep 15
- done
- echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on PyPI within 2.5 minutes"
- exit 1
+ # shellcheck source=scripts/ci/registry-probe.sh
+ . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh"
+ wait_for_registry \
+ "${PACKAGE_NAME}@${PACKAGE_VERSION} on PyPI" \
+ "https://pypi.org/pypi/${PACKAGE_NAME}/${PACKAGE_VERSION}/json" 10 15
publishRelease:
runs-on: ubuntu-latest
diff --git a/.github/workflows/release-audit.yml b/.github/workflows/release-audit.yml
index d12141d0..8c481f89 100644
--- a/.github/workflows/release-audit.yml
+++ b/.github/workflows/release-audit.yml
@@ -52,7 +52,12 @@ jobs:
audit:
runs-on: ubuntu-latest
- timeout-minutes: 10
+ # The wait below can take up to 20 minutes on its own.
+ timeout-minutes: 35
+ permissions:
+ contents: read
+ # Required so `gh run list` can see this commit's other workflow runs.
+ actions: read
steps:
- uses: actions/checkout@v7
with:
@@ -61,9 +66,46 @@ jobs:
uses: actions/setup-node@v7
with:
node-version: '22'
+ # On a push the audit is triggered by the very commit whose publish
+ # workflows are still running, so without this wait it compares the
+ # just-bumped version against a registry that cannot possibly have it
+ # yet. Run 33168552493 finished 8 seconds after the push and reported all
+ # seven languages as drifted while rust was still publishing, six minutes
+ # from done. Every one of those warnings was noise.
+ - name: Wait for this commit's publish workflows
+ if: ${{ github.event_name == 'push' }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ CI_VERBOSE: ${{ inputs.verbose && 'true' || vars.CI_VERBOSE || 'false' }}
+ run: |
+ set -euo pipefail
+ for attempt in $(seq 1 60); do
+ # This workflow is itself a run for this commit, so it has to be
+ # excluded or the wait can never finish.
+ PENDING=$(gh run list --commit "$GITHUB_SHA" --limit 100 \
+ --json name,status \
+ --jq '[.[] | select(.name != "release-audit")
+ | select(.status != "completed")] | length')
+ if [ "$PENDING" -eq 0 ]; then
+ echo "All workflows for $GITHUB_SHA have completed (attempt ${attempt})"
+ exit 0
+ fi
+ if [ "${CI_VERBOSE:-false}" = "true" ]; then
+ gh run list --commit "$GITHUB_SHA" --limit 100 \
+ --json name,status,conclusion \
+ --jq '.[] | " \(.name): \(.status) \(.conclusion // "")"'
+ fi
+ echo "${PENDING} workflow(s) still running (attempt ${attempt}/60); waiting 20s"
+ sleep 20
+ done
+ echo "::warning::Workflows for $GITHUB_SHA were still running after 20 minutes; the audit below may report releases that are still in flight"
# Drift is reported as annotations rather than enforced as a failure: a
# version bump legitimately lands before the release that publishes it.
- name: Audit declared versions against the registries
env:
CI_VERBOSE: ${{ inputs.verbose && 'true' || vars.CI_VERBOSE || 'false' }}
+ # On a pull request no publish job runs at all, so a bumped version
+ # being ahead of the registry is the expected state rather than
+ # drift. Reporting it as a warning there would flag every release PR.
+ AUDIT_EXPECT_PUBLISHED: ${{ github.event_name != 'pull_request' }}
run: node scripts/release-audit.mjs
diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
index 6ee916bb..9523ff73 100644
--- a/.github/workflows/rust.yml
+++ b/.github/workflows/rust.yml
@@ -150,10 +150,17 @@ jobs:
- name: Build
run: cargo build --release
# Exchanges the workflow's OIDC identity for a short-lived crates.io
- # token. Returns an empty token when trusted publishing is not
- # configured for the crate, which the publish step reports explicitly.
+ # token. Trusted publishing has to be registered on crates.io before it
+ # can be used; until that is done the action fails with
+ # "No Trusted Publishing config found" and, because it writes an
+ # `::error::` annotation, paints a red cross on a run that then publishes
+ # perfectly well through CARGO_TOKEN. Gating on an opt-in repository
+ # variable keeps that false positive out of the run summary. Set the
+ # `CRATES_IO_TRUSTED_PUBLISHING` variable to `true` after configuring
+ # https://crates.io/crates/links-notation/settings to switch over.
- name: Authenticate to crates.io
id: cratesio-auth
+ if: ${{ vars.CRATES_IO_TRUSTED_PUBLISHING == 'true' }}
continue-on-error: true
uses: rust-lang/crates-io-auth-action@v1
- name: Publish to crates.io
@@ -165,6 +172,8 @@ jobs:
CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }}
run: |
set -euo pipefail
+ # shellcheck source=scripts/ci/registry-probe.sh
+ . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh"
read_field() { grep "^$2 = " "$1" | head -1 | sed "s/$2 = \"\(.*\)\"/\1/"; }
MACRO_VERSION=$(read_field links-notation-macro/Cargo.toml version)
@@ -188,6 +197,15 @@ jobs:
publish_crate() {
local crate="$1" version="$2" log="$3"
+ # Every other language workflow checks the registry before pushing.
+ # Without it `cargo publish` prints a red "already exists on
+ # crates.io index" error on any re-run of an already released
+ # version, which reads as a failure in a job that is working
+ # exactly as intended.
+ if crate_version_published "$crate" "$version"; then
+ echo "${crate}@${version} is already on crates.io"
+ return 2
+ fi
if cargo publish -p "$crate" 2>&1 | tee "$log"; then
echo "Published ${crate}@${version}"
return 0
@@ -209,14 +227,11 @@ jobs:
if [ $MACRO_STATUS -eq 1 ]; then exit 1; fi
if [ $MACRO_STATUS -eq 0 ]; then
- for attempt in $(seq 1 20); do
- if curl -fsS "https://crates.io/api/v1/crates/links-notation-macro/${MACRO_VERSION}" >/dev/null 2>&1; then
- echo "links-notation-macro@${MACRO_VERSION} is visible in the index"
- break
- fi
- echo "Waiting for the index to catch up (attempt ${attempt}/20)"
- sleep 15
- done
+ # The sparse index, not the JSON API, is what `cargo publish`
+ # resolves the dependency against, so that is what has to catch up.
+ if ! wait_for_crate_version links-notation-macro "$MACRO_VERSION" 20 15; then
+ echo "::warning::Continuing anyway; cargo will fail explicitly if the dependency is still unresolvable"
+ fi
fi
set +e
@@ -235,16 +250,14 @@ jobs:
PACKAGE_VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail
- for attempt in $(seq 1 20); do
- if curl -fsS "https://crates.io/api/v1/crates/${PACKAGE_NAME}/${PACKAGE_VERSION}" >/dev/null 2>&1; then
- echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on crates.io (attempt ${attempt})"
- exit 0
- fi
- echo "Not visible yet, retrying in 15s (attempt ${attempt}/20)"
- sleep 15
- done
- echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on crates.io within 5 minutes"
- exit 1
+ # shellcheck source=scripts/ci/registry-probe.sh
+ . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh"
+ # Check the index first: it is the artifact consumers actually
+ # resolve against, and unlike the JSON API it is not rate limited.
+ wait_for_crate_version "$PACKAGE_NAME" "$PACKAGE_VERSION" 20 15
+ wait_for_registry \
+ "${PACKAGE_NAME}@${PACKAGE_VERSION} on crates.io" \
+ "https://crates.io/api/v1/crates/${PACKAGE_NAME}/${PACKAGE_VERSION}" 20 15
publishRelease:
runs-on: ubuntu-latest
diff --git a/.github/workflows/workflows.yml b/.github/workflows/workflows.yml
index 38516981..81a85745 100644
--- a/.github/workflows/workflows.yml
+++ b/.github/workflows/workflows.yml
@@ -11,9 +11,11 @@ on:
branches: main
paths:
- '.github/**'
+ - 'scripts/ci/**'
pull_request:
paths:
- '.github/**'
+ - 'scripts/ci/**'
workflow_dispatch:
permissions:
@@ -40,6 +42,28 @@ jobs:
with:
args: -color
+ ci-scripts:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+ # actionlint only reaches shell that is inlined in a `run:` block, so the
+ # shared helper the publish workflows source needs its own gate.
+ - name: Run shellcheck
+ run: |
+ set -euo pipefail
+ sudo apt-get update
+ sudo apt-get install --yes shellcheck
+ shellcheck -x scripts/ci/*.sh
+ # Guards the fix for issue #298: the registry probes have to send an
+ # identifying User-Agent, because crates.io answers 403 without one. The
+ # test runs against a local server, so it needs no network and cannot go
+ # flaky.
+ - name: Test the registry probe
+ run: bash scripts/ci/registry-probe.test.sh
+
zizmor:
runs-on: ubuntu-latest
timeout-minutes: 10
diff --git a/.gitignore b/.gitignore
index 86e8e3e5..c92f5498 100644
--- a/.gitignore
+++ b/.gitignore
@@ -359,3 +359,11 @@ csharp/fvextra.sty
# lychee link-checker cache (see .github/workflows/links.yml)
.lycheecache
+
+# The development log is a deliverable, not build output. Several rules above
+# are meant for build artefacts but match it by accident: `[Ll]og/` matches the
+# `dev/log/` directory itself, and `*.log` matches the CI logs collected under
+# it. Without these two lines every `dev/log` file has to be force-added, which
+# is how it gets forgotten.
+!dev/log/
+!dev/log/**
diff --git a/.gitkeep b/.gitkeep
index d365b986..487e7055 100644
--- a/.gitkeep
+++ b/.gitkeep
@@ -1,3 +1,4 @@
# .gitkeep file auto-generated at 2026-08-28T06:09:39.810Z for PR creation at branch issue-288-cc91e23553e8 for issue https://github.com/link-foundation/links-notation/issues/288
# Updated: 2026-08-28T07:33:05.436Z
-# Updated: 2026-08-28T11:11:17.987Z
\ No newline at end of file
+# Updated: 2026-08-28T11:11:17.987Z
+# Updated: 2026-08-28T12:23:46.151Z
\ No newline at end of file
diff --git a/csharp/Link.Foundation.Links.Notation/Link.Foundation.Links.Notation.csproj b/csharp/Link.Foundation.Links.Notation/Link.Foundation.Links.Notation.csproj
index 4e6dc31a..d75d347f 100644
--- a/csharp/Link.Foundation.Links.Notation/Link.Foundation.Links.Notation.csproj
+++ b/csharp/Link.Foundation.Links.Notation/Link.Foundation.Links.Notation.csproj
@@ -4,7 +4,7 @@
Link.Foundation's Links Notation parser and formatter for .NET
Konstantin Diachenko
Link.Foundation.Links.Notation
- 0.16.0
+ 0.16.1
Konstantin Diachenko
net10.0
Link.Foundation.Links.Notation
diff --git a/dev/log/issues/298/pulls/299/CI-CD-BEST-PRACTICES.md b/dev/log/issues/298/pulls/299/CI-CD-BEST-PRACTICES.md
new file mode 100644
index 00000000..f68503e6
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/CI-CD-BEST-PRACTICES.md
@@ -0,0 +1,446 @@
+
+
+# CI/CD Best Practices for AI-Driven Development (languages: en • [zh](https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.zh.md) • [hi](https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.hi.md) • [ru](https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.ru.md))
+
+This document describes CI/CD best practices that significantly improve the quality and reliability of AI-driven development workflows. When properly configured, Hive Mind AI solvers are forced to iterate with CI/CD checks until all tests pass, ensuring code quality meets the highest standards.
+
+## Why CI/CD Matters for AI Development
+
+Hive Mind's AI issue solver is instructed to pay attention to CI/CD checks in each pull request. This creates a powerful feedback loop:
+
+1. **AI creates a solution** - The solver generates code based on issue requirements
+2. **CI/CD validates the solution** - Automated checks verify code quality
+3. **AI iterates until passing** - The solver fixes issues until all checks pass
+4. **Quality is guaranteed** - No code merges without passing all gates
+
+This approach ensures consistent quality regardless of whether the team consists of humans, AIs, or both.
+
+## Recommended CI/CD Templates
+
+We provide ready-to-use templates for multiple languages with all best practices pre-configured:
+
+| Language | Template Repository |
+| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
+| JavaScript/TypeScript | [js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) |
+| Rust | [rust-ai-driven-development-pipeline-template](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template) |
+| Python | [python-ai-driven-development-pipeline-template](https://github.com/link-foundation/python-ai-driven-development-pipeline-template) |
+| Go | [go-ai-driven-development-pipeline-template](https://github.com/link-foundation/go-ai-driven-development-pipeline-template) |
+| C# | [csharp-ai-driven-development-pipeline-template](https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template) |
+| Java | [java-ai-driven-development-pipeline-template](https://github.com/link-foundation/java-ai-driven-development-pipeline-template) |
+| PHP | [php-ai-driven-development-pipeline-template](https://github.com/link-foundation/php-ai-driven-development-pipeline-template) |
+
+> **Tip:** You don't have to pick a template by hand. Run `fix --ci-cd` (see [Automatic CI/CD Remediation](#automatic-cicd-remediation)) and Hive Mind detects the repository's languages and selects the matching templates for you.
+
+## Key CI/CD Principles
+
+### 1. Run Checks Only on Relevant File Changes
+
+**Only trigger checks when relevant files change.** This dramatically reduces CI costs and run times.
+
+Use a `detect-changes` job at the start of your workflow to determine which file categories changed:
+
+```yaml
+jobs:
+ detect-changes:
+ runs-on: ubuntu-latest
+ outputs:
+ code-changed: ${{ steps.changes.outputs.code }}
+ docs-changed: ${{ steps.changes.outputs.docs }}
+ docker-changed: ${{ steps.changes.outputs.docker }}
+ workflow-changed: ${{ steps.changes.outputs.workflow }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+ - name: Detect changes
+ id: changes
+ run: node scripts/detect-code-changes.mjs
+```
+
+Then gate each job on the relevant output:
+
+```yaml
+test-suites:
+ needs: [detect-changes]
+ if: needs.detect-changes.outputs.code-changed == 'true' || needs.detect-changes.outputs.workflow-changed == 'true'
+ # ...
+
+validate-docs:
+ needs: [detect-changes]
+ if: needs.detect-changes.outputs.docs-changed == 'true'
+ # ...
+
+docker-pr-check:
+ needs: [detect-changes]
+ if: needs.detect-changes.outputs.docker-changed == 'true' || needs.detect-changes.outputs.workflow-changed == 'true'
+ # ...
+```
+
+**What to exclude from "code changes" detection:**
+
+- Markdown files (`*.md`) — documentation-only changes don't need changeset files
+- `.changeset/` folder — changeset metadata isn't code
+- `data/` and `experiments/` folders — non-production content
+- `.gitkeep` files — placeholder files with no functional impact
+
+**What always triggers checks when changed:**
+
+- Source code files (`.mjs`, `.ts`, `.py`, `.rs`, `.go`, etc.)
+- `package.json` / dependency manifests
+- CI/CD workflow files (`.github/workflows/*.yml`)
+- `Dockerfile` and related infrastructure files
+
+### 2. File Size Limits
+
+**Enforce a maximum of 1000-1500 lines per code file.**
+
+This constraint benefits both AI and human developers:
+
+- AI models can read and understand entire files within context windows
+- Humans can navigate and comprehend files without cognitive overload
+- Forces modular, well-organized code architecture
+
+Example enforcement in CI (bash):
+
+```bash
+find src/ -name "*.mjs" -type f | while read -r file; do
+ line_count=$(wc -l < "$file")
+ if [ "$line_count" -gt 1500 ]; then
+ echo "ERROR: $file has $line_count lines (limit: 1500)"
+ echo "::error file=$file::File has $line_count lines (limit: 1500)"
+ exit 1
+ fi
+done
+```
+
+**Synchronize the file-size ESLint rule with the CI check** to catch violations locally before CI:
+
+```js
+// eslint.config.mjs
+{
+ rules: {
+ 'max-lines': ['error', { max: 1500 }]
+ }
+}
+```
+
+### 3. Automated Code Formatting
+
+Consistent formatting eliminates style debates and reduces diff noise:
+
+| Language | Tool |
+| --------------------- | ----------------------------- |
+| JavaScript/TypeScript | ESLint + Prettier |
+| Rust | rustfmt |
+| Python | Ruff |
+| Go | gofmt |
+| C# | dotnet format |
+| Java | Spotless (Google Java Format) |
+| PHP | PHP CS Fixer |
+
+All templates include pre-commit hooks that run formatters automatically before each commit.
+
+### 4. Static Analysis & Linting
+
+Catch bugs and enforce patterns before code reaches review:
+
+| Language | Tools |
+| --------------------- | ----------------------------------- |
+| JavaScript/TypeScript | ESLint with strict rules |
+| Rust | Clippy (pedantic + nursery) |
+| Python | Ruff + mypy |
+| Go | go vet + staticcheck |
+| C# | .NET analyzers (warnings as errors) |
+| Java | SpotBugs (maximum effort) |
+| PHP | PHPStan (max level) |
+
+### 5. Fast-Fail Job Ordering
+
+**Run fast checks before slow checks** to give the fastest possible feedback:
+
+```
+Fast checks (~7-30s each): Slow checks (~1-10 min each):
+├── test-compilation ├── test-suites (unit tests)
+├── lint (format + ESLint) ├── test-execution (integration)
+└── check-file-line-limits ├── docker-pr-check
+ └── helm-pr-check
+```
+
+Gate slow checks on fast checks:
+
+```yaml
+test-suites:
+ needs: [test-compilation, lint, check-file-line-limits]
+ if: |
+ always() &&
+ !cancelled() &&
+ !contains(needs.*.result, 'failure') &&
+ needs.test-compilation.result == 'success' &&
+ needs.lint.result == 'success' &&
+ needs.check-file-line-limits.result == 'success'
+```
+
+### 6. Changeset-Based Versioning
+
+All templates use a changeset system that:
+
+- **Eliminates merge conflicts** - Each PR creates an independent changeset file
+- **Automates version bumps** - Highest bump type wins when merging
+- **Generates changelogs** - Release notes are compiled automatically
+- **Supports semantic versioning** - patch/minor/major bumps are explicit
+
+| Language | Tool |
+| --------------------- | ---------------------------- |
+| JavaScript/TypeScript | @changesets/cli |
+| Rust | changelog.d + custom scripts |
+| Python | Scriv |
+| PHP | changelog.d + custom scripts |
+| Go, C#, Java | Custom changeset workflows |
+
+**Exempt docs-only PRs from changeset requirements:**
+
+```yaml
+changeset-check:
+ needs: [detect-changes]
+ if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true'
+```
+
+Documentation-only changes (updating `.md` files) should not require a version bump.
+
+### 7. Validate the Actual Merge Result
+
+**CI must test what will actually be merged, not a stale PR snapshot.**
+
+When a PR is opened against a base branch that later receives new commits, the GitHub merge preview can become stale. Simulate a fresh merge before running checks:
+
+```yaml
+- name: Simulate fresh merge with base branch (PR only)
+ if: github.event_name == 'pull_request'
+ env:
+ BASE_REF: ${{ github.base_ref }}
+ run: |
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git config user.name "github-actions[bot]"
+ git fetch origin "$BASE_REF"
+ BEHIND_COUNT=$(git rev-list --count HEAD..origin/$BASE_REF)
+ if [ "$BEHIND_COUNT" -gt 0 ]; then
+ git merge origin/$BASE_REF --no-edit || \
+ (echo "::error::Merge conflict! PR must be rebased before merging." && exit 1)
+ fi
+```
+
+This ensures lint, file-size, and other checks validate the final merged state.
+
+### 8. Pre-commit Hooks
+
+Local quality gates prevent broken commits from reaching CI:
+
+1. Format check and auto-fix
+2. Lint and static analysis
+3. Type checking (where applicable)
+4. File size validation
+5. Secrets detection
+
+This "shift left" approach catches issues immediately rather than waiting for CI.
+
+### 9. Release Automation
+
+Automated release workflows ensure:
+
+- **No manual version management** - Versions update automatically
+- **OIDC trusted publishing** - No API tokens needed in CI (npm, PyPI, crates.io)
+- **Validated releases only** - All checks must pass before publishing
+- **Dual trigger modes** - Both automatic (on merge) and manual (workflow dispatch)
+
+**Prohibit manual version changes** in PRs — all version bumps should be managed by the CI release workflow:
+
+```yaml
+version-check:
+ if: github.event_name == 'pull_request'
+ steps:
+ - name: Check for version changes in package.json
+ run: node scripts/check-version.mjs
+```
+
+### 10. Concurrency Control
+
+**Separate cancellable read-only checks from non-cancellable write jobs.** Configure concurrency at the job level when a workflow contains both kinds of work:
+
+```yaml
+jobs:
+ lint:
+ # Include the job identity (and matrix values, when present) so unrelated
+ # checks remain parallel while a newer run replaces only the stale check.
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-lint
+ cancel-in-progress: true
+ # ...
+
+ deploy:
+ needs: [lint]
+ if: ${{ !cancelled() && needs.lint.result == 'success' }}
+ # Every job that writes to main or an external deployment target uses this
+ # repository-wide group, even when the jobs live in different workflows.
+ concurrency:
+ group: main-writer-${{ github.repository }}-main
+ cancel-in-progress: false
+ # ...
+```
+
+- **Read-only jobs:** Cancel superseded checks on both pull requests and `main` to reduce runner load. Give each job a distinct suffix; include relevant matrix values so different matrix entries can still run in parallel.
+- **Dependent writers:** Use `needs` and require successful prerequisites. A cancelled prerequisite must make its write job not start.
+- **Active writers:** Give every release, deploy, tag, generated-content push, and other write job the same repository-scoped group with `cancel-in-progress: false`. An already started writer finishes while the next writer waits in the queue, including writers from another workflow file.
+- **Workflow scope:** Do not put cancellable concurrency at workflow level when the workflow has write jobs. Cancelling the workflow would also interrupt a writer that has already started.
+
+By default, a concurrency group keeps at most one running and one pending job; a newer pending writer replaces the older pending writer. If every queued write must run, add `queue: max` to the writer's concurrency block (up to 100 jobs can wait). `queue: max` cannot be combined with `cancel-in-progress: true`, and execution order follows when jobs start waiting rather than workflow dispatch order, so write jobs should remain idempotent. See [GitHub's concurrency documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) for the current queue limits and semantics.
+
+Use `!cancelled()` instead of `always()` in job conditions so cancellation propagates correctly through the job graph. A bare `always()` can keep downstream work running after cancellation.
+
+### 11. Secrets Detection
+
+Prevent accidental credential leaks in CI:
+
+- Include a secrets scan step using tools like `secretlint` or `truffleHog`
+- Fail CI immediately if secrets are detected
+- Never log environment variables or token values
+
+### 12. Documentation Validation
+
+**Validate documentation files in CI just like code:**
+
+- Check file size limits (e.g., max 2500 lines for docs)
+- Verify required sections exist in key documents
+- Check for broken links using tools like `lychee`
+
+```yaml
+validate-docs:
+ needs: [detect-changes]
+ if: needs.detect-changes.outputs.docs-changed == 'true'
+ steps:
+ - run: node tests/docs-validation.mjs
+```
+
+### 13. Container Images: Native Runners per Architecture
+
+**Build each architecture on its own native runner.** GitHub provides free arm64 Linux runners for public repositories (`ubuntu-24.04-arm`). Emulating arm64 with QEMU on an x86 runner is much slower for compiled languages, and building two architectures inside one job makes them sequential instead of parallel.
+
+```yaml
+build-image:
+ strategy:
+ matrix:
+ include:
+ - platform: linux/amd64
+ runner: ubuntu-latest
+ - platform: linux/arm64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
+ steps:
+ - uses: docker/build-push-action@v7
+ with:
+ platforms: ${{ matrix.platform }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ outputs: type=image,push-by-digest=true,name-canonical=true,push=true
+
+merge-manifest:
+ needs: [build-image]
+ steps:
+ - run: docker buildx imagetools create -t $IMAGE:$VERSION $DIGESTS
+```
+
+- **No `setup-qemu-action`.** Its presence means an architecture is being emulated; use a native runner instead.
+- **Publish images for every architecture your users run.** A single-architecture image silently excludes Apple Silicon, Graviton, and arm CI runners.
+- **Always cache.** Set `cache-from: type=gha` and `cache-to: type=gha,mode=max` on every build step; otherwise every architecture rebuilds the full dependency tree for every release.
+- **Never gate the release on the image push.** Publish the GitHub Release and language-registry package first, then attach images as they finish. Release notes contain no data derived from image bytes, so a slow or failed registry push must not hide an otherwise completed release.
+- **Assert what you shipped.** Verify that the published manifest lists every intended platform and that each default-branch tag has a corresponding GitHub Release; a missing release is otherwise easy to overlook.
+
+Reference implementations: [`link-foundation/box`](https://github.com/link-foundation/box) and [`link-assistant/hive-mind`](https://github.com/link-assistant/hive-mind).
+
+## Quality Enforcement Strategy
+
+The templates implement a defense-in-depth approach:
+
+```
+Developer Machine → CI/CD Pipeline → Release
+├── Pre-commit hooks ├── detect-changes ├── All checks pass
+├── Local tests ├── version-check ├── Version bump
+└── IDE integration ├── changeset-check ├── Changelog update
+ ├── test-compilation └── Publish package
+ ├── lint (format+ESLint)
+ ├── check-file-line-limits
+ ├── test-suites
+ ├── test-execution
+ ├── validate-docs
+ └── docker-pr-check
+```
+
+Each layer catches different issues, ensuring no problematic code reaches production.
+
+## Getting Started
+
+1. **Choose a template** from the table above matching your language
+2. **Use it as a GitHub template** to create your new repository
+3. **Configure secrets** if needed for publishing (OIDC preferred)
+4. **Start developing** with all best practices pre-configured
+
+The AI solvers will automatically respect and iterate with all configured checks, producing higher quality output than repositories without CI/CD enforcement.
+
+## Automatic CI/CD Remediation
+
+For an existing repository, you don't need to apply these practices by hand. The `fix` command automates the whole flow:
+
+```bash
+fix https://github.com/owner/repo --ci-cd
+```
+
+This command:
+
+1. **Detects the repository's languages** using the GitHub Linguist API (`GET /repos/{owner}/{repo}/languages`), ordered by the number of bytes per language.
+2. **Selects the matching CI/CD templates** from the table above, sorted so the template for the most-used language comes first.
+3. **Inspects the latest default-branch commit** and collects its CI/CD runs (falling back to the most recent runs on the default branch when the latest commit has none).
+4. **Creates a remediation issue** that lists the failing runs, the detected languages, the recommended templates, and a link back to this document. The issue is created as a **Bug** (with a `bug` label) and its title and text are taken from the [standard remediation template](https://github.com/link-assistant/web-capture/issues/139).
+5. **Hands the issue off to `/solve --development-log --deep-analysis --auto-merge`**, which iterates until the fixes are merged. Every option `fix` does not consume itself (for example `--tool`, `--model`, `--think`) is forwarded to `/solve`.
+
+### Why the issue is a Bug, and what it leaves out
+
+`--development-log` replaces the template's retired case-study-folder instruction and collects artifacts under `./dev/log/issues/{issue-id}/pulls/{pull-id}`. `/fix` never emits the retired paragraph, including with `--no-solve` or partial option sets. `--deep-analysis` supplies the timeline, root-cause, debug-output, and upstream-reporting guidance, so `fix` conditionally omits the matching paragraphs instead of delivering them twice.
+
+That omission is only lossless because `/solve` emits the root-cause wording **only for bug-typed issues** — which is why `fix` creates the issue as a Bug. Issue types are configured per organization and labels per repository, so if the target repository accepts neither, the issue is still created without them.
+
+The retired paragraph cannot be restored by an option combination; `--development-log` is the only supported collection workflow. The remaining conditional omissions are controlled by `--deep-analysis`.
+
+### Language → Template Mapping
+
+The command maps detected languages to templates as follows (JavaScript and TypeScript share a single template):
+
+| Detected Language(s) | Template |
+| --------------------- | ---------------------------------------------------------------- |
+| JavaScript/TypeScript | `link-foundation/js-ai-driven-development-pipeline-template` |
+| Rust | `link-foundation/rust-ai-driven-development-pipeline-template` |
+| Python | `link-foundation/python-ai-driven-development-pipeline-template` |
+| Go | `link-foundation/go-ai-driven-development-pipeline-template` |
+| C# | `link-foundation/csharp-ai-driven-development-pipeline-template` |
+| Java | `link-foundation/java-ai-driven-development-pipeline-template` |
+| PHP | `link-foundation/php-ai-driven-development-pipeline-template` |
+
+Languages without a dedicated template (for example Shell or Dockerfile) are listed in the issue for awareness, and the closest matching template is recommended.
+
+Use `--dry-run` to preview the issue without creating it, and `--no-solve` to create the issue without starting `/solve`:
+
+```bash
+fix owner/repo --ci-cd --dry-run
+fix owner/repo --ci-cd --no-solve
+```
+
+## References
+
+- [Code Architecture Principles](https://github.com/link-foundation/code-architecture-principles)
+- [Contributing Guidelines](https://github.com/link-assistant/hive-mind/blob/main/docs/CONTRIBUTING.md)
+- [Best Practices](https://github.com/link-assistant/hive-mind/blob/main/docs/BEST-PRACTICES.md)
diff --git a/dev/log/issues/298/pulls/299/analysis/README.md b/dev/log/issues/298/pulls/299/analysis/README.md
new file mode 100644
index 00000000..52e422d9
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/analysis/README.md
@@ -0,0 +1,352 @@
+# Issue #298 — analysis
+
+Evidence for this analysis is in the sibling folders:
+
+| Folder | Contents |
+| --- | --- |
+| `../ci-logs/` | Raw job logs from the failing run 33168552506 |
+| `../api/` | Job and annotation inventories for all 15 runs of the push |
+| `../templates/` | The seven pipeline templates' workflows, as downloaded |
+| `../CI-CD-BEST-PRACTICES.md` | The referenced best-practices document |
+
+Two edits were made to the collected evidence, both because a repository-wide
+check flagged them and both preserving the content:
+
+- `../CI-CD-BEST-PRACTICES.md` carries a provenance header and its five
+ repository-relative links were rewritten to absolute hive-mind URLs, since
+ they cannot resolve from this directory. This is the same treatment the copy
+ under `dev/log/issues/290/pulls/291/analysis/` received.
+- `../ci-logs/rust-33168552506-publishToCratesIO.log` had the UTF-8 BOM that
+ `gh run view --log` prepends stripped from byte 1, so it passes `bom-check`.
+ Nothing else in the file was touched.
+
+Both were caught by this PR's own CI run 33172747906 / 33172748012, whose logs
+are also in `../ci-logs/`. They are recorded here because they are exactly the
+kind of finding issue #298 asks for: the checks were right, the newly added
+files were wrong.
+
+## 1. Timeline
+
+Everything below happened on one push to `main` (commit `2b829f3`, "Merge pull
+request #294"), which bumped every implementation from 0.15.0 to 0.16.0.
+
+| Time (UTC) | Event |
+| --- | --- |
+| 11:48:28 | The push starts all 15 workflow runs simultaneously |
+| 11:48:36 | `release-audit` finishes, **8 seconds in**, and annotates all seven languages as drifted |
+| 11:49:42 | `php` finishes; warns that the package is not registered on Packagist |
+| 11:50:09 | `cargo publish` reports `Published links-notation v0.16.0 at registry crates-io` |
+| 11:50:09 | The next step begins probing `https://crates.io/api/v1/crates/links-notation/0.16.0` |
+| 11:50:52 | `python` finishes; warns that `attestations: true` was ignored |
+| 11:50:09 → 11:55:10 | Twenty attempts, 15 seconds apart, every one reported as "Not visible yet" |
+| 11:55:10 | `::error::links-notation@0.16.0 did not appear on crates.io within 5 minutes`; the run goes red |
+| 2026-08-28 (now) | `crates.io` serves 0.16.0, and so do npm, PyPI, NuGet and proxy.golang.org |
+
+The last row is the decisive one. Running the repository's own audit today:
+
+```
+js: 0.16.0 (in sync with npm)
+python: 0.16.0 (in sync with PyPI)
+rust: 0.16.0 (in sync with crates.io)
+csharp: 0.16.0 (in sync with NuGet.org)
+go: 0.16.0 (in sync with proxy.golang.org)
+```
+
+The release the workflow declared missing had already shipped when it said so.
+
+## 2. Requirements
+
+Enumerated from the issue text, with where each is addressed.
+
+| # | Requirement | Status |
+| --- | --- | --- |
+| R1 | Fix the one failing default-branch workflow (`rust`, run 33168552506) | Done — §3.1 |
+| R2 | Find and fix **false negatives** across CI/CD | Done — §3.1 |
+| R3 | Find and fix **false positives** across CI/CD | Done — §3.2, §3.3 |
+| R4 | Find and fix **warnings** | Done — §3.3, §3.4; §5 lists what remains and why |
+| R5 | Find and fix **errors** | Done — §3.1, §3.2, §3.5 |
+| R6 | Compare **all files** against the seven pipeline templates | Done — §4 |
+| R7 | Report the same defect upstream in the templates where it exists | Done — two issues filed, §4.2 |
+| R8 | Follow `link-assistant/hive-mind` CI/CD best practices | Done — §6 |
+| R9 | Apply each fix **everywhere** it applies, not only where it failed | Done — §3.1, 11 call sites across 7 workflows |
+| R10 | Add debug output and a verbose mode, default off | Done — §7 |
+| R11 | Everything in the single PR #299 | Done |
+
+## 3. Root causes
+
+### 3.1 False negative: crates.io rejects unidentified clients (the failing run)
+
+The verification step ran, in full:
+
+```bash
+for attempt in $(seq 1 20); do
+ if curl -fsS "https://crates.io/api/v1/crates/${PACKAGE_NAME}/${PACKAGE_VERSION}" >/dev/null 2>&1; then
+ echo "Verified ..."; exit 0
+ fi
+ echo "Not visible yet, retrying in 15s (attempt ${attempt}/20)"
+ sleep 15
+done
+echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on crates.io within 5 minutes"
+exit 1
+```
+
+**crates.io answers 403 to clients that do not identify themselves**, and curl's
+default `User-Agent: curl/8.x` is one of them. `-f` turns 403 into a non-zero
+exit, so all twenty attempts failed for a reason unrelated to the release.
+
+Reproduced in `experiments/issue-298/registry-user-agent-probe.sh`:
+
+```
+crates.io default-UA=403 explicit-UA=200 <-- DIFFERS
+npm default-UA=200 explicit-UA=200 same
+pypi default-UA=200 explicit-UA=200 same
+nuget default-UA=200 explicit-UA=200 same
+goproxy default-UA=200 explicit-UA=200 same
+packagist default-UA=404 explicit-UA=404 same
+```
+
+Only crates.io discriminates, which is why only the rust workflow failed — the
+identical pattern in the other six workflows happened to be talking to
+registries that tolerate it. That is luck, not correctness.
+
+A second, compounding defect: `>/dev/null 2>&1` discarded the status code. Had
+the log said `HTTP 403` even once, this would have been a five-minute
+diagnosis rather than an investigation. The message the step did print —
+"did not appear on crates.io" — asserts something the step had no evidence for.
+
+The repository already knew the rule. `scripts/release-audit.mjs:28` has always
+sent `'user-agent': 'links-notation-release-audit'`. The knowledge simply never
+reached the workflow.
+
+**Fix.** `scripts/ci/registry-probe.sh`, sourced by all seven workflows:
+
+- every request carries an identifying `User-Agent`;
+- the last observed status is always in the failure message, so 404 (indexing
+ lag) is distinguishable from 403 (broken probe);
+- `CI_VERBOSE=true` logs every attempt's status. Default off.
+
+Applied to **all 11 registry call sites across 7 workflows** (R9), not only the
+one that failed.
+
+### 3.2 False positive: an `::error::` annotation for an unconfigured optional feature
+
+`rust-lang/crates-io-auth-action@v1` fails with
+
+```
+Failed to retrieve token from Cargo registry. Status: 400.
+Error: No Trusted Publishing config found for repository `link-foundation/links-notation`.
+```
+
+Trusted publishing has to be registered on crates.io first, and it has not
+been. The step carried `continue-on-error: true`, so the *job* survived — but
+the action writes an `::error::` annotation, and annotations are not suppressed
+by `continue-on-error`. Every run got a red error for a fallback path that
+worked perfectly.
+
+**Fix.** Gate the step on an opt-in repository variable
+(`vars.CRATES_IO_TRUSTED_PUBLISHING == 'true'`), so it does not run — and
+cannot annotate — until trusted publishing is actually configured.
+
+### 3.3 False positive: the audit races the publishes it audits
+
+`release-audit` triggers on `push` to `main`. So do the seven publish
+workflows. The audit finished **8 seconds** after the push; rust was still
+publishing **six minutes later**. Comparing a just-bumped version against a
+registry that has not been written to yet can only produce drift, so all seven
+warnings were structurally guaranteed:
+
+```
+::warning::rust: declared 0.16.0, latest on crates.io is 0.15.0.
+```
+
+Today crates.io serves 0.16.0. The warnings described a race, not drift.
+
+**Fix.** On `push`, wait for this commit's other workflow runs to complete
+before comparing. On `pull_request` no publish job runs at all, so being ahead
+of the registry is the expected state there and is reported as a notice.
+
+### 3.4 Contradictory inputs: PyPI attestations
+
+`pypa/gh-action-pypi-publish` defaults `attestations` to true, but attestations
+require trusted publishing, and the step passes `PYPI_TOKEN`. Every release
+therefore logged:
+
+```
+::warning::The workflow was run with the 'attestations: true' input, but an
+explicit password was also set, disabling Trusted Publishing. As a result, the
+attestations input is ignored.
+```
+
+The two inputs contradicted each other and the action was right to say so.
+
+**Fix.** Both now derive from one variable (`vars.PYPI_TRUSTED_PUBLISHING`), so
+they cannot disagree.
+
+### 3.5 Log noise: publishing a crate that is already published
+
+The rust workflow re-publishes `links-notation-macro` on every run, so any run
+where only the main crate was bumped logs
+
+```
+error: crate links-notation-macro@0.1.0 already exists on crates.io index
+```
+
+The calling function handled it and reported "skipped", so the job was correct,
+but a red `error:` line in a healthy run is exactly the noise this issue is
+about. Every other language workflow has a "Check if version already published"
+step; rust did not.
+
+**Fix.** `crate_version_published` checks the sparse index before publishing.
+
+A note on why the *index* and not the JSON API: `cargo` resolves dependencies
+against `https://index.crates.io//`, so that is the artifact whose
+visibility actually matters. The index returns 200 for **any** crate that
+exists, so the version has to be matched inside the document — the first draft
+of this helper checked only the status and would have reported every version of
+an existing crate as published. Three tests now cover it, including that `0.1`
+must not match `0.16.0`.
+
+## 4. Template comparison (R6, R7)
+
+### 4.1 What the templates do
+
+None of the seven templates uses bare `curl` to verify a release. Each has a
+dedicated script in its own language:
+
+| Template | Script | Sends a `User-Agent`? | Reports the status on failure? |
+| --- | --- | --- | --- |
+| rust | `scripts/wait-for-crate.rs` | yes | partially — per-attempt warning only |
+| csharp | `scripts/wait-for-nuget.mjs` | n/a (`fetch`) | **yes** — returns `{available, status, url}` |
+| js | `scripts/wait-for-npm.mjs` | n/a (`npm view`) | **no** — bare `catch { return false }` |
+| php | `scripts/wait-for-packagist.php` | n/a | fails open, continues |
+| python, go, java | none | — | — |
+
+So the specific trigger for #298 — a missing `User-Agent` — **does not exist
+upstream**. The rust template sets it correctly. This repository's bash
+reimplementation dropped it. No upstream report is warranted for that.
+
+### 4.2 What does exist upstream
+
+The *class* of defect does. Two templates collapse "the registry did not
+answer" into "the package is not published", and then print a message
+asserting the release failed:
+
+**`rust-ai-driven-development-pipeline-template`** — `wait-for-crate.rs`:
+
+```rust
+Ok(response) => response.status() == 200,
+Err(ureq::Error::Status(404, _)) => false, // genuinely not published
+Err(e) => { eprintln!("Warning: ..."); false } // 403, 429, 5xx -> "not published"
+```
+
+A `bool` cannot carry the difference, so the caller cannot report it either.
+Filed:
+
+**`js-ai-driven-development-pipeline-template`** — `wait-for-npm.mjs`:
+
+```js
+} catch {
+ return false; // E404, EAI_AGAIN, 429, 503, npm-not-on-PATH: all identical
+}
+```
+
+The bare `catch {}` discards the error object entirely.
+Filed:
+
+Both reports include a runnable reproduction, a workaround, and a concrete
+code-level fix, and both point at the C# template as the in-family reference
+implementation — `wait-for-nuget.mjs` already returns the status alongside the
+verdict, which is exactly the shape the other two need.
+
+## 5. Annotations that remain, and why
+
+These are accurate statements about optional integrations that are genuinely
+not configured. Silencing them would replace a false positive with a false
+negative, which is the failure mode this issue exists to remove.
+
+| Annotation | Severity | Why it stays |
+| --- | --- | --- |
+| `php: not registered on Packagist` | warning | True. Requires a human to submit the package once at packagist.org. |
+| `java: Skipping Maven Central publishing: CENTRAL_USERNAME... not configured` | warning | True. Requires Sonatype credentials as repository secrets. |
+| `go: CODECOV_TOKEN is not configured` | notice | True, and already at the right severity. |
+| `python: Trusted Publishers allows publishing...` | warning | Emitted by the action while a token is in use. Removable by setting `PYPI_TRUSTED_PUBLISHING` once trusted publishing is registered. |
+| `links: Summary report available at ...` | notice | Informational by design. |
+
+Each is a prompt for a one-time configuration action, listed in §8.
+
+## 6. Best-practices conformance (R8)
+
+Checked against `../CI-CD-BEST-PRACTICES.md`. Every language workflow already
+had path filters, per-job `concurrency` groups with
+`cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}`, `timeout-minutes`
+on every job, `persist-credentials: false` on every checkout, and fast-fail
+ordering (`lint` → `test` → `publish`). `AutoMerge.yml` has no checkout, so
+credential persistence does not apply.
+
+Two gaps this PR closes:
+
+- **§4 Static Analysis & Linting** — `actionlint` only reaches shell inlined in
+ a `run:` block, so the new shared helper had no lint gate. The `workflows`
+ workflow now runs `shellcheck -x scripts/ci/*.sh` and the helper's test suite,
+ and its path filter now includes `scripts/ci/**` so changes to it are covered.
+- **§9 Release Automation** — verification steps that cannot distinguish
+ "not published" from "could not check" are not verification. Addressed in §3.1.
+
+## 7. Debug output and verbose mode (R10)
+
+The repository already had a `CI_VERBOSE` convention, default off:
+
+```yaml
+CI_VERBOSE: ${{ inputs.verbose && 'true' || vars.CI_VERBOSE || 'false' }}
+```
+
+The registry polls never used it, which is why the failing run produced twenty
+identical lines and no diagnosis. Now:
+
+- `CI_VERBOSE=true` logs `probe -> HTTP ` for every attempt, and the
+ full set of versions the sparse index returned;
+- the last observed status is in the final `::error::` **unconditionally**, so
+ the next occurrence is diagnosable from the default log;
+- the audit's new wait step lists each pending workflow under `CI_VERBOSE`.
+
+Default remains off. `scripts/ci/registry-probe.test.sh` asserts both halves:
+silence by default, status codes when asked.
+
+## 8. Follow-up configuration (needs repository/registry access)
+
+Not code changes; each removes one remaining warning.
+
+1. Register trusted publishing at , then set the `CRATES_IO_TRUSTED_PUBLISHING` repository variable to `true`.
+2. Register trusted publishing at , then set `PYPI_TRUSTED_PUBLISHING` to `true` and drop the `PYPI_TOKEN` secret.
+3. Submit the package once at .
+4. Add `CENTRAL_USERNAME`, `CENTRAL_TOKEN`, `GPG_PRIVATE_KEY` and `GPG_PASSPHRASE` for Maven Central.
+5. Optionally add `CODECOV_TOKEN`.
+
+## 9. Existing components surveyed
+
+| Component | Verdict |
+| --- | --- |
+| [`rust-lang/crates-io-auth-action`](https://github.com/rust-lang/crates-io-auth-action) | Already in use; now gated so it cannot annotate before it is configured. |
+| [`pypa/gh-action-pypi-publish`](https://github.com/pypa/gh-action-pypi-publish) | Already in use; its inputs no longer contradict each other. |
+| The templates' `wait-for-*` scripts | Reviewed in §4. `wait-for-nuget.mjs` is the reference shape; the others are the subject of the upstream reports. |
+| `cargo publish --dry-run`, `cargo-release`, `release-plz` | Rejected. They manage version bumping and publication, which already work here; the defect was in verification, which none of them owns. |
+| A generic HTTP-retry action (e.g. `nick-fields/retry`) | Rejected. Retrying was never the problem — every retry did exactly what it was told. The problem was that the predicate was wrong and its result was discarded. |
+
+The fix is 164 lines of shell with no new dependency, which matters for a step
+whose whole job is to be more trustworthy than the thing it verifies.
+
+## 10. Reproduction and regression test
+
+- `experiments/issue-298/registry-user-agent-probe.sh` — probes six real
+ registries with and without an identifying `User-Agent`. This is what
+ established the root cause.
+- `scripts/ci/registry-probe.test.sh` — 21 assertions against a local server
+ that reproduces the crates.io 403. No network, so it cannot go flaky. It
+ fails against the pre-fix behaviour:
+
+ ```
+ NOT OK - probe_registry got HTTP 403, so it is not sending a real User-Agent
+ NOT OK - the status code is recorded (expected '200', got '403')
+ ```
+
+ Both run in CI via the `ci-scripts` job in `.github/workflows/workflows.yml`.
diff --git a/dev/log/issues/298/pulls/299/api/all-annotations.txt b/dev/log/issues/298/pulls/299/api/all-annotations.txt
new file mode 100644
index 00000000..ad6b1c33
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/api/all-annotations.txt
@@ -0,0 +1,17 @@
+33168552493|warning|.github|php: declared 0.16.0, but nothing is published on Packagist. The publish job for this language has never successfully released anything.
+33168552493|warning|.github|java: declared 0.16.0, but nothing is published on Maven Central. The publish job for this language has never successfully released anything.
+33168552493|warning|.github|go: declared 0.16.0, latest on proxy.golang.org is 0.15.0.
+33168552493|warning|.github|csharp: declared 0.16.0, latest on NuGet.org is 0.15.0.
+33168552493|warning|.github|rust: declared 0.16.0, latest on crates.io is 0.15.0.
+33168552493|warning|.github|python: declared 0.16.0, latest on PyPI is 0.15.0.
+33168552493|warning|.github|js: declared 0.16.0, latest on npm is 0.15.0.
+33168552483|notice|.github|Summary report available at: https://github.com/link-foundation/links-notation/actions/runs/33168552483#summary-98839751833
+33168552511|notice|.github|CODECOV_TOKEN is not configured, so coverage was not uploaded
+33168552472|warning|.github|link-foundation/links-notation is not registered on Packagist (repo.packagist.org returns 404). Submit it once at https://packagist.org/packages/submit; until then no PHP release will be created.
+33168552529|warning|.github|Skipping Maven Central publishing: CENTRAL_USERNAME, CENTRAL_TOKEN, GPG_PRIVATE_KEY and GPG_PASSPHRASE secrets are not configured. Generate a token at https://central.sonatype.com/account and add these repository secrets to enable publishing. No GitHub release will be created.
+33168552480|warning|.github|A new Trusted Publisher for the currently running publishing workflow can be created by accessing the following link(s) while logged-in as an owner of the package(s):
+33168552480|warning|.github|Trusted Publishers allows publishing packages to PyPI from automated environments like GitHub Actions without needing to use username/password combinations or API tokens to authenticate with PyPI. Read more: https://docs.pypi.org/trusted-publishers
+33168552480|warning|.github|The workflow was run with the 'attestations: true' input, but an explicit password was also set, disabling Trusted Publishing. As a result, the attestations input is ignored.
+33168552506|failure|.github|Process completed with exit code 1.
+33168552506|failure|.github|links-notation@0.16.0 did not appear on crates.io within 5 minutes
+33168552506|failure|.github|Failed to retrieve token from Cargo registry. Status: 400. Error: No Trusted Publishing config found for repository `link-foundation/links-notation`.
diff --git a/dev/log/issues/298/pulls/299/api/all-jobs.txt b/dev/log/issues/298/pulls/299/api/all-jobs.txt
new file mode 100644
index 00000000..26bf507a
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/api/all-jobs.txt
@@ -0,0 +1,73 @@
+=== run 33169010058
+findChangedDocsFiles|success
+build|success
+deploy|success
+=== run 33168552493
+audit|success
+consistency|success
+=== run 33168552483
+linkChecker|success
+=== run 33168552490
+check-bom-consistency|success
+=== run 33168552532
+actionlint|success
+zizmor|success
+=== run 33168552511
+findChangedGoFiles|success
+lint|success
+test|success
+publishRelease|success
+=== run 33168552472
+findChangedPhpFiles|success
+lint|success
+test (8.4)|success
+test (8.5)|success
+publishToPackagist|success
+publishRelease|skipped
+=== run 33168552485
+findChangedJsFiles|success
+format|success
+lint|success
+test|success
+publishToNpm|success
+publishRelease|success
+=== run 33168552529
+findChangedJavaFiles|success
+format|success
+build|success
+test (21)|success
+test (25)|success
+publishToMavenCentral|success
+publishRelease|skipped
+=== run 33168552480
+findChangedPythonFiles|success
+lint|success
+test|success
+publishToPyPI|success
+publishRelease|success
+=== run 33168552512
+Secret scan|success
+CodeQL (javascript-typescript)|success
+CodeQL (rust)|success
+Audit npm lock (docs/website)|success
+Audit npm lock (js)|success
+CodeQL (python)|success
+CodeQL (actions)|success
+CodeQL (go)|success
+CodeQL (csharp)|success
+CodeQL (java-kotlin)|success
+Dependency review|skipped
+=== run 33168552506
+findChangedRustFiles|success
+lint|success
+test|success
+publishToCratesIO|failure
+publishRelease|skipped
+=== run 33168552491
+findChangedCsFiles|success
+lint|success
+test|success
+generatePdfWithCode|success
+pushToNuget|success
+publishDocumentation|success
+publishRelease|success
diff --git a/dev/log/issues/298/pulls/299/api/rust-33168552506-jobs.json b/dev/log/issues/298/pulls/299/api/rust-33168552506-jobs.json
new file mode 100644
index 00000000..e734d597
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/api/rust-33168552506-jobs.json
@@ -0,0 +1 @@
+{"conclusion":"failure","createdAt":"2026-08-28T11:48:28Z","displayTitle":"Merge pull request #294 from link-foundation/issue-292-a602b59a2375","headSha":"2b829f37b8369ff5a4b5114923eae4d49f03c78d","jobs":[{"completedAt":"2026-08-28T11:48:35Z","conclusion":"success","databaseId":98839751757,"name":"findChangedRustFiles","startedAt":"2026-08-28T11:48:30Z","status":"completed","steps":[{"completedAt":"2026-08-28T11:48:32Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-28T11:48:31Z","status":"completed"},{"completedAt":"2026-08-28T11:48:33Z","conclusion":"success","name":"Run actions/checkout@v7","number":2,"startedAt":"2026-08-28T11:48:32Z","status":"completed"},{"completedAt":"2026-08-28T11:48:33Z","conclusion":"success","name":"Get changed files using defaults","number":3,"startedAt":"2026-08-28T11:48:33Z","status":"completed"},{"completedAt":"2026-08-28T11:48:33Z","conclusion":"success","name":"Set output isRustFilesChanged","number":4,"startedAt":"2026-08-28T11:48:33Z","status":"completed"},{"completedAt":"2026-08-28T11:48:34Z","conclusion":"success","name":"Post Run actions/checkout@v7","number":8,"startedAt":"2026-08-28T11:48:33Z","status":"completed"},{"completedAt":"2026-08-28T11:48:34Z","conclusion":"success","name":"Complete job","number":9,"startedAt":"2026-08-28T11:48:34Z","status":"completed"}],"url":"https://github.com/link-foundation/links-notation/actions/runs/33168552506/job/98839751757"},{"completedAt":"2026-08-28T11:49:25Z","conclusion":"success","databaseId":98839776041,"name":"lint","startedAt":"2026-08-28T11:48:49Z","status":"completed","steps":[{"completedAt":"2026-08-28T11:48:51Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-28T11:48:50Z","status":"completed"},{"completedAt":"2026-08-28T11:48:52Z","conclusion":"success","name":"Run actions/checkout@v7","number":2,"startedAt":"2026-08-28T11:48:51Z","status":"completed"},{"completedAt":"2026-08-28T11:48:54Z","conclusion":"success","name":"Setup Rust","number":3,"startedAt":"2026-08-28T11:48:52Z","status":"completed"},{"completedAt":"2026-08-28T11:48:58Z","conclusion":"success","name":"Cache Cargo","number":4,"startedAt":"2026-08-28T11:48:54Z","status":"completed"},{"completedAt":"2026-08-28T11:49:02Z","conclusion":"success","name":"Check formatting","number":5,"startedAt":"2026-08-28T11:48:58Z","status":"completed"},{"completedAt":"2026-08-28T11:49:23Z","conclusion":"success","name":"Run Clippy","number":6,"startedAt":"2026-08-28T11:49:02Z","status":"completed"},{"completedAt":"2026-08-28T11:49:23Z","conclusion":"success","name":"Post Cache Cargo","number":11,"startedAt":"2026-08-28T11:49:23Z","status":"completed"},{"completedAt":"2026-08-28T11:49:23Z","conclusion":"success","name":"Post Run actions/checkout@v7","number":12,"startedAt":"2026-08-28T11:49:23Z","status":"completed"},{"completedAt":"2026-08-28T11:49:23Z","conclusion":"success","name":"Complete job","number":13,"startedAt":"2026-08-28T11:49:23Z","status":"completed"}],"url":"https://github.com/link-foundation/links-notation/actions/runs/33168552506/job/98839776041"},{"completedAt":"2026-08-28T11:49:43Z","conclusion":"success","databaseId":98839951168,"name":"test","startedAt":"2026-08-28T11:49:28Z","status":"completed","steps":[{"completedAt":"2026-08-28T11:49:30Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-28T11:49:29Z","status":"completed"},{"completedAt":"2026-08-28T11:49:32Z","conclusion":"success","name":"Run actions/checkout@v7","number":2,"startedAt":"2026-08-28T11:49:30Z","status":"completed"},{"completedAt":"2026-08-28T11:49:32Z","conclusion":"success","name":"Setup Rust","number":3,"startedAt":"2026-08-28T11:49:32Z","status":"completed"},{"completedAt":"2026-08-28T11:49:34Z","conclusion":"success","name":"Cache Cargo","number":4,"startedAt":"2026-08-28T11:49:32Z","status":"completed"},{"completedAt":"2026-08-28T11:49:36Z","conclusion":"success","name":"Build","number":5,"startedAt":"2026-08-28T11:49:34Z","status":"completed"},{"completedAt":"2026-08-28T11:49:41Z","conclusion":"success","name":"Test","number":6,"startedAt":"2026-08-28T11:49:36Z","status":"completed"},{"completedAt":"2026-08-28T11:49:41Z","conclusion":"success","name":"Post Cache Cargo","number":11,"startedAt":"2026-08-28T11:49:41Z","status":"completed"},{"completedAt":"2026-08-28T11:49:41Z","conclusion":"success","name":"Post Run actions/checkout@v7","number":12,"startedAt":"2026-08-28T11:49:41Z","status":"completed"},{"completedAt":"2026-08-28T11:49:41Z","conclusion":"success","name":"Complete job","number":13,"startedAt":"2026-08-28T11:49:41Z","status":"completed"}],"url":"https://github.com/link-foundation/links-notation/actions/runs/33168552506/job/98839951168"},{"completedAt":"2026-08-28T11:55:13Z","conclusion":"failure","databaseId":98840013712,"name":"publishToCratesIO","startedAt":"2026-08-28T11:49:46Z","status":"completed","steps":[{"completedAt":"2026-08-28T11:49:48Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-28T11:49:47Z","status":"completed"},{"completedAt":"2026-08-28T11:49:50Z","conclusion":"success","name":"Run actions/checkout@v7","number":2,"startedAt":"2026-08-28T11:49:48Z","status":"completed"},{"completedAt":"2026-08-28T11:49:50Z","conclusion":"success","name":"Setup Rust","number":3,"startedAt":"2026-08-28T11:49:50Z","status":"completed"},{"completedAt":"2026-08-28T11:49:53Z","conclusion":"success","name":"Cache Cargo","number":4,"startedAt":"2026-08-28T11:49:50Z","status":"completed"},{"completedAt":"2026-08-28T11:50:02Z","conclusion":"success","name":"Build","number":5,"startedAt":"2026-08-28T11:49:53Z","status":"completed"},{"completedAt":"2026-08-28T11:50:03Z","conclusion":"success","name":"Authenticate to crates.io","number":6,"startedAt":"2026-08-28T11:50:02Z","status":"completed"},{"completedAt":"2026-08-28T11:50:09Z","conclusion":"success","name":"Publish to crates.io","number":7,"startedAt":"2026-08-28T11:50:03Z","status":"completed"},{"completedAt":"2026-08-28T11:55:10Z","conclusion":"failure","name":"Verify the crate is really on crates.io","number":8,"startedAt":"2026-08-28T11:50:09Z","status":"completed"},{"completedAt":"2026-08-28T11:55:10Z","conclusion":"success","name":"Post Authenticate to crates.io","number":14,"startedAt":"2026-08-28T11:55:10Z","status":"completed"},{"completedAt":"2026-08-28T11:55:10Z","conclusion":"skipped","name":"Post Cache Cargo","number":15,"startedAt":"2026-08-28T11:55:10Z","status":"completed"},{"completedAt":"2026-08-28T11:55:11Z","conclusion":"success","name":"Post Run actions/checkout@v7","number":16,"startedAt":"2026-08-28T11:55:10Z","status":"completed"},{"completedAt":"2026-08-28T11:55:11Z","conclusion":"success","name":"Complete job","number":17,"startedAt":"2026-08-28T11:55:11Z","status":"completed"}],"url":"https://github.com/link-foundation/links-notation/actions/runs/33168552506/job/98840013712"},{"completedAt":"2026-08-28T11:55:13Z","conclusion":"skipped","databaseId":98841191334,"name":"publishRelease","startedAt":"2026-08-28T11:55:14Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/links-notation/actions/runs/33168552506/job/98841191334"}]}
diff --git a/dev/log/issues/298/pulls/299/ci-logs/bom-check-33172747906.log b/dev/log/issues/298/pulls/299/ci-logs/bom-check-33172747906.log
new file mode 100644
index 00000000..bca99800
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/ci-logs/bom-check-33172747906.log
@@ -0,0 +1,61 @@
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7738470Z ##[group]Run echo "Checking for files with Unicode BOM..."
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7739852Z ^[[36;1mecho "Checking for files with Unicode BOM..."^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7741091Z ^[[36;1mecho "Repository standard: NO BOM (UTF-8 without BOM)"^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7742160Z ^[[36;1mecho ""^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7742688Z ^[[36;1m^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7743462Z ^[[36;1m# Find all text files that contain BOM (EF BB BF at start)^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7744975Z ^[[36;1m# Exclude binary files, .git directory, and common binary extensions^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7746237Z ^[[36;1mfiles_with_bom=""^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7747184Z ^[[36;1m^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7747829Z ^[[36;1mwhile IFS= read -r -d '' file; do^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7748841Z ^[[36;1m # Check if file starts with UTF-8 BOM (EF BB BF)^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7750073Z ^[[36;1m if head -c 3 "$file" | od -An -tx1 | grep -q "ef bb bf"; then^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7751295Z ^[[36;1m files_with_bom="$files_with_bom$file"$'\n'^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7752197Z ^[[36;1m fi^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7752790Z ^[[36;1mdone < <(find . -type f \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7753545Z ^[[36;1m -not -path './.git/*' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7754291Z ^[[36;1m -not -name '*.png' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7754997Z ^[[36;1m -not -name '*.jpg' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7755715Z ^[[36;1m -not -name '*.jpeg' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7756435Z ^[[36;1m -not -name '*.gif' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7757259Z ^[[36;1m -not -name '*.ico' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7757980Z ^[[36;1m -not -name '*.woff' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7758703Z ^[[36;1m -not -name '*.woff2' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7759451Z ^[[36;1m -not -name '*.ttf' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7760149Z ^[[36;1m -not -name '*.eot' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7760852Z ^[[36;1m -not -name '*.pdf' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7761550Z ^[[36;1m -not -name '*.zip' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7762268Z ^[[36;1m -not -name '*.tar' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7762971Z ^[[36;1m -not -name '*.gz' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7763675Z ^[[36;1m -not -name '*.lock' \^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7764380Z ^[[36;1m -print0)^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7764940Z ^[[36;1m^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7765464Z ^[[36;1mif [ -n "$files_with_bom" ]; then^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7766710Z ^[[36;1m echo "::error::The following files contain a Unicode BOM but should not"^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7768642Z ^[[36;1m echo "ERROR: The following files contain Unicode BOM but should not:"^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7769909Z ^[[36;1m echo "$files_with_bom"^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7770617Z ^[[36;1m echo ""^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7771393Z ^[[36;1m echo "To fix this, remove the BOM from these files."^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7772947Z ^[[36;1m # printf, not echo: the point is to show the escape sequences^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7774280Z ^[[36;1m # themselves, and echo is allowed to expand them.^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7775596Z ^[[36;1m printf '%s\n' "You can use: sed -i '1s/^\xEF\xBB\xBF//' "^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7777130Z ^[[36;1m echo "Or configure your editor to save files without BOM."^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7778213Z ^[[36;1m exit 1^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7778735Z ^[[36;1melse^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7779565Z ^[[36;1m echo "SUCCESS: All text files are consistent (no BOM found)."^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7780664Z ^[[36;1m exit 0^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7781171Z ^[[36;1mfi^[[0m
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7828846Z shell: /usr/bin/bash -e {0}
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7829602Z ##[endgroup]
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.8009888Z Checking for files with Unicode BOM...
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.8012057Z Repository standard: NO BOM (UTF-8 without BOM)
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.8013123Z
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8236450Z ##[error]The following files contain a Unicode BOM but should not
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8244854Z ERROR: The following files contain Unicode BOM but should not:
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8245970Z ./dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-publishToCratesIO.log
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8246700Z
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8246714Z
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8247356Z To fix this, remove the BOM from these files.
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8248520Z You can use: sed -i '1s/^\xEF\xBB\xBF//'
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8249914Z Or configure your editor to save files without BOM.
+check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8255414Z ##[error]Process completed with exit code 1.
diff --git a/dev/log/issues/298/pulls/299/ci-logs/links-33172748012.log b/dev/log/issues/298/pulls/299/ci-logs/links-33172748012.log
new file mode 100644
index 00000000..c28a3d2e
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/ci-logs/links-33172748012.log
@@ -0,0 +1,152 @@
+linkChecker Check links with lychee 2026-08-28T12:50:32.2262349Z ##[group]Run lycheeverse/lychee-action@v2
+linkChecker Check links with lychee 2026-08-28T12:50:32.2262623Z with:
+linkChecker Check links with lychee 2026-08-28T12:50:32.2263341Z args: --no-progress --cache --max-cache-age 1d --max-retries 3 --timeout 30 --exclude-path docs/case-studies --exclude-path docs/website/dist --exclude-path js/node_modules --exclude-path docs/website/node_modules './**/*.md'
+linkChecker Check links with lychee 2026-08-28T12:50:32.2264375Z fail: true
+linkChecker Check links with lychee 2026-08-28T12:50:32.2264557Z jobSummary: true
+linkChecker Check links with lychee 2026-08-28T12:50:32.2264725Z debug: false
+linkChecker Check links with lychee 2026-08-28T12:50:32.2264886Z failIfEmpty: true
+linkChecker Check links with lychee 2026-08-28T12:50:32.2265052Z format: markdown
+linkChecker Check links with lychee 2026-08-28T12:50:32.2265222Z lycheeVersion: v0.24.2
+linkChecker Check links with lychee 2026-08-28T12:50:32.2265413Z output: lychee/out.md
+linkChecker Check links with lychee 2026-08-28T12:50:32.2265588Z checkbox: true
+linkChecker Check links with lychee 2026-08-28T12:50:32.2267834Z token: ***
+linkChecker Check links with lychee 2026-08-28T12:50:32.2268018Z workingDirectory: .
+linkChecker Check links with lychee 2026-08-28T12:50:32.2268197Z env:
+linkChecker Check links with lychee 2026-08-28T12:50:32.2268353Z CI_VERBOSE: false
+linkChecker Check links with lychee 2026-08-28T12:50:32.2270309Z GITHUB_TOKEN: ***
+linkChecker Check links with lychee 2026-08-28T12:50:32.2270480Z ##[endgroup]
+linkChecker Check links with lychee 2026-08-28T12:50:32.2327294Z ##[start-action display=Set up environment;id=__lycheeverse_lychee-action.__run]
+linkChecker Check links with lychee 2026-08-28T12:50:32.2369515Z ##[group]Run # Install into $RUNNER_TEMP (not $HOME) so the install path is stable
+linkChecker Check links with lychee 2026-08-28T12:50:32.2370356Z ^[[36;1m# Install into $RUNNER_TEMP (not $HOME) so the install path is stable^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2370782Z ^[[36;1m# across composite steps. On some runners $HOME is overridden per step^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2371177Z ^[[36;1m# (e.g. actions/checkout temporarily sets it), which made the PATH^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2371546Z ^[[36;1m# entry from one step and the install in another step diverge.^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2371866Z ^[[36;1mecho "$RUNNER_TEMP/lychee/bin" >> "$GITHUB_PATH"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2372154Z ^[[36;1mmkdir -p "$RUNNER_TEMP/lychee/bin"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2412454Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+linkChecker Check links with lychee 2026-08-28T12:50:32.2412779Z env:
+linkChecker Check links with lychee 2026-08-28T12:50:32.2412966Z CI_VERBOSE: false
+linkChecker Check links with lychee 2026-08-28T12:50:32.2415378Z GITHUB_TOKEN: ***
+linkChecker Check links with lychee 2026-08-28T12:50:32.2415606Z ##[endgroup]
+linkChecker Check links with lychee 2026-08-28T12:50:32.2527724Z ##[end-action id=__lycheeverse_lychee-action.__run;outcome=success;conclusion=success;duration_ms=19]
+linkChecker Check links with lychee 2026-08-28T12:50:32.2532124Z ##[start-action display=Download and extract lychee in temp directory;id=__lycheeverse_lychee-action.lychee-setup]
+linkChecker Check links with lychee 2026-08-28T12:50:32.2552856Z ##[group]Run # Create a temporary directory for downloads and extraction
+linkChecker Check links with lychee 2026-08-28T12:50:32.2553302Z ^[[36;1m# Create a temporary directory for downloads and extraction^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2553876Z ^[[36;1mTEMP_DIR="${RUNNER_TEMP}/lychee-download"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2554160Z ^[[36;1mrm -rf "${TEMP_DIR}" && mkdir -p "${TEMP_DIR}"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2554408Z ^[[36;1mcd "${TEMP_DIR}"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2554588Z ^[[36;1m^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2554745Z ^[[36;1mARCH=$(uname -m)^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2554999Z ^[[36;1m# Determine filename and download URL based on version^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2555334Z ^[[36;1mif [[ "${LYCHEE_VERSION}" =~ ^v0\.0|^v0\.1[0-5]\. ]]; then^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2555709Z ^[[36;1m FILENAME="lychee-${LYCHEE_VERSION}-${ARCH}-unknown-linux-gnu.tar.gz"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2556242Z ^[[36;1m DOWNLOAD_URL="https://github.com/lycheeverse/lychee/releases/download/${LYCHEE_VERSION}/${FILENAME}"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2556664Z ^[[36;1melse^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2556916Z ^[[36;1m FILENAME="lychee-${ARCH}-unknown-linux-gnu.tar.gz"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2557287Z ^[[36;1m if [[ "${LYCHEE_VERSION}" == 'nightly' ]]; then^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2557676Z ^[[36;1m DOWNLOAD_URL="https://github.com/lycheeverse/lychee/releases/download/nightly/${FILENAME}"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2558067Z ^[[36;1m elif [[ "${LYCHEE_VERSION}" == 'latest' ]]; then^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2558452Z ^[[36;1m DOWNLOAD_URL="https://github.com/lycheeverse/lychee/releases/latest/download/${FILENAME}"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2558799Z ^[[36;1m else^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2559153Z ^[[36;1m DOWNLOAD_URL="https://github.com/lycheeverse/lychee/releases/download/lychee-${LYCHEE_VERSION}/${FILENAME}"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2559776Z ^[[36;1m fi^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2559932Z ^[[36;1mfi^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2560091Z ^[[36;1m^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2560284Z ^[[36;1mecho "Downloading from: ${DOWNLOAD_URL}"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2560534Z ^[[36;1mcurl -sfLO "${DOWNLOAD_URL}"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2560736Z ^[[36;1m^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2560898Z ^[[36;1mecho "Extracting ${FILENAME}"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2561126Z ^[[36;1mtar -xvzf "${FILENAME}"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2561336Z ^[[36;1mrm -rv "${FILENAME}"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2561519Z ^[[36;1m^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2561735Z ^[[36;1m# Detect if lychee binary is in a subfolder within the tar.gz^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2562029Z ^[[36;1mif ! [[ -f "${TEMP_DIR}/lychee" ]]; then^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2562343Z ^[[36;1m TEMP_DIR="$(echo "${TEMP_DIR}"/lychee-*)" # need `echo` to evaluate glob^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2562629Z ^[[36;1mfi^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2562803Z ^[[36;1mif ! [[ -f "${TEMP_DIR}/lychee" ]]; then^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2563141Z ^[[36;1m echo "Cannot find lychee binary in $TEMP_DIR. Did the archive structure change?" >&2^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2563662Z ^[[36;1m exit 1^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2563993Z ^[[36;1mfi^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2564144Z ^[[36;1m^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2564345Z ^[[36;1m# Output temp directory for use in later steps^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2564628Z ^[[36;1mecho "temp_dir=${TEMP_DIR}" >> $GITHUB_OUTPUT^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.2602878Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+linkChecker Check links with lychee 2026-08-28T12:50:32.2603206Z env:
+linkChecker Check links with lychee 2026-08-28T12:50:32.2603382Z CI_VERBOSE: false
+linkChecker Check links with lychee 2026-08-28T12:50:32.2605843Z GITHUB_TOKEN: ***
+linkChecker Check links with lychee 2026-08-28T12:50:32.2606036Z LYCHEE_VERSION: v0.24.2
+linkChecker Check links with lychee 2026-08-28T12:50:32.2606227Z ##[endgroup]
+linkChecker Check links with lychee 2026-08-28T12:50:32.2705801Z Downloading from: https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-unknown-linux-gnu.tar.gz
+linkChecker Check links with lychee 2026-08-28T12:50:32.7045326Z Extracting lychee-x86_64-unknown-linux-gnu.tar.gz
+linkChecker Check links with lychee 2026-08-28T12:50:32.7072647Z lychee-x86_64-unknown-linux-gnu/
+linkChecker Check links with lychee 2026-08-28T12:50:32.7073615Z lychee-x86_64-unknown-linux-gnu/README.md
+linkChecker Check links with lychee 2026-08-28T12:50:32.7074181Z lychee-x86_64-unknown-linux-gnu/docs/
+linkChecker Check links with lychee 2026-08-28T12:50:32.7074767Z lychee-x86_64-unknown-linux-gnu/docs/lychee.1
+linkChecker Check links with lychee 2026-08-28T12:50:32.7075377Z lychee-x86_64-unknown-linux-gnu/docs/TROUBLESHOOTING.md
+linkChecker Check links with lychee 2026-08-28T12:50:32.7075974Z lychee-x86_64-unknown-linux-gnu/docs/PRE_COMMIT.md
+linkChecker Check links with lychee 2026-08-28T12:50:32.7076519Z lychee-x86_64-unknown-linux-gnu/complete/
+linkChecker Check links with lychee 2026-08-28T12:50:32.7077160Z lychee-x86_64-unknown-linux-gnu/complete/_lychee.ps1
+linkChecker Check links with lychee 2026-08-28T12:50:32.7077776Z lychee-x86_64-unknown-linux-gnu/complete/lychee.elv
+linkChecker Check links with lychee 2026-08-28T12:50:32.7078400Z lychee-x86_64-unknown-linux-gnu/complete/lychee.fish
+linkChecker Check links with lychee 2026-08-28T12:50:32.7078995Z lychee-x86_64-unknown-linux-gnu/complete/lychee.bash
+linkChecker Check links with lychee 2026-08-28T12:50:32.7079579Z lychee-x86_64-unknown-linux-gnu/complete/_lychee
+linkChecker Check links with lychee 2026-08-28T12:50:32.7080125Z lychee-x86_64-unknown-linux-gnu/lychee
+linkChecker Check links with lychee 2026-08-28T12:50:32.8303100Z removed 'lychee-x86_64-unknown-linux-gnu.tar.gz'
+linkChecker Check links with lychee 2026-08-28T12:50:32.8332047Z ##[end-action id=__lycheeverse_lychee-action.lychee-setup;outcome=success;conclusion=success;duration_ms=579]
+linkChecker Check links with lychee 2026-08-28T12:50:32.8337090Z ##[start-action display=Install lychee;id=__lycheeverse_lychee-action.__run_2]
+linkChecker Check links with lychee 2026-08-28T12:50:32.8370856Z ##[group]Run # Install lychee from the temporary directory
+linkChecker Check links with lychee 2026-08-28T12:50:32.8371215Z ^[[36;1m# Install lychee from the temporary directory^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.8371711Z ^[[36;1minstall -t "$RUNNER_TEMP/lychee/bin" -D "/home/runner/work/_temp/lychee-download/lychee-x86_64-unknown-linux-gnu/lychee"^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.8406941Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+linkChecker Check links with lychee 2026-08-28T12:50:32.8407246Z env:
+linkChecker Check links with lychee 2026-08-28T12:50:32.8407424Z CI_VERBOSE: false
+linkChecker Check links with lychee 2026-08-28T12:50:32.8409617Z GITHUB_TOKEN: ***
+linkChecker Check links with lychee 2026-08-28T12:50:32.8409807Z ##[endgroup]
+linkChecker Check links with lychee 2026-08-28T12:50:32.8551274Z ##[end-action id=__lycheeverse_lychee-action.__run_2;outcome=success;conclusion=success;duration_ms=21]
+linkChecker Check links with lychee 2026-08-28T12:50:32.8559745Z ##[start-action display=Run Lychee;id=__lycheeverse_lychee-action.run-lychee]
+linkChecker Check links with lychee 2026-08-28T12:50:32.8577398Z ##[group]Run /home/runner/work/_actions/lycheeverse/lychee-action/v2/entrypoint.sh
+linkChecker Check links with lychee 2026-08-28T12:50:32.8577876Z ^[[36;1m/home/runner/work/_actions/lycheeverse/lychee-action/v2/entrypoint.sh^[[0m
+linkChecker Check links with lychee 2026-08-28T12:50:32.8611955Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+linkChecker Check links with lychee 2026-08-28T12:50:32.8612255Z env:
+linkChecker Check links with lychee 2026-08-28T12:50:32.8612427Z CI_VERBOSE: false
+linkChecker Check links with lychee 2026-08-28T12:50:32.8614790Z GITHUB_TOKEN: ***
+linkChecker Check links with lychee 2026-08-28T12:50:32.8616804Z INPUT_TOKEN: ***
+linkChecker Check links with lychee 2026-08-28T12:50:32.8617479Z INPUT_ARGS: --no-progress --cache --max-cache-age 1d --max-retries 3 --timeout 30 --exclude-path docs/case-studies --exclude-path docs/website/dist --exclude-path js/node_modules --exclude-path docs/website/node_modules './**/*.md'
+linkChecker Check links with lychee 2026-08-28T12:50:32.8618181Z INPUT_DEBUG: false
+linkChecker Check links with lychee 2026-08-28T12:50:32.8618357Z INPUT_FAIL: true
+linkChecker Check links with lychee 2026-08-28T12:50:32.8618530Z INPUT_FAILIFEMPTY: true
+linkChecker Check links with lychee 2026-08-28T12:50:32.8618723Z INPUT_FORMAT: markdown
+linkChecker Check links with lychee 2026-08-28T12:50:32.8618906Z INPUT_JOBSUMMARY: true
+linkChecker Check links with lychee 2026-08-28T12:50:32.8619101Z INPUT_CHECKBOX: true
+linkChecker Check links with lychee 2026-08-28T12:50:32.8619283Z INPUT_OUTPUT: lychee/out.md
+linkChecker Check links with lychee 2026-08-28T12:50:32.8619922Z SUMMARY_URL: https://github.com/link-foundation/links-notation/actions/runs/33172748012#summary-98853657524
+linkChecker Check links with lychee 2026-08-28T12:50:32.8620322Z ##[endgroup]
+linkChecker Check links with lychee 2026-08-28T12:50:38.6439139Z Hint: Followed 11 redirects. You might want to consider replacing redirecting URLs with the resolved URLs. Use verbose mode (`-v`/`-vv`) to see redirection details.
+linkChecker Check links with lychee 2026-08-28T12:50:38.6530453Z # Summary
+linkChecker Check links with lychee 2026-08-28T12:50:38.6530687Z
+linkChecker Check links with lychee 2026-08-28T12:50:38.6530808Z | Status | Count |
+linkChecker Check links with lychee 2026-08-28T12:50:38.6531139Z |----------------|-------|
+linkChecker Check links with lychee 2026-08-28T12:50:38.6531725Z | 🔍 Total | 1132 |
+linkChecker Check links with lychee 2026-08-28T12:50:38.6532046Z | 🔗 Unique | 992 |
+linkChecker Check links with lychee 2026-08-28T12:50:38.6532400Z | ✅ Successful | 1118 |
+linkChecker Check links with lychee 2026-08-28T12:50:38.6532818Z | ⏳ Timeouts | 0 |
+linkChecker Check links with lychee 2026-08-28T12:50:38.6533278Z | 🔀 Redirected | 11 |
+linkChecker Check links with lychee 2026-08-28T12:50:38.6533821Z | 👻 Excluded | 9 |
+linkChecker Check links with lychee 2026-08-28T12:50:38.6534230Z | ❓ Unknown | 0 |
+linkChecker Check links with lychee 2026-08-28T12:50:38.6534545Z | 🚫 Errors | 5 |
+linkChecker Check links with lychee 2026-08-28T12:50:38.6534902Z | ⛔ Unsupported | 0 |
+linkChecker Check links with lychee 2026-08-28T12:50:38.6535066Z
+linkChecker Check links with lychee 2026-08-28T12:50:38.6535165Z ## Errors per input
+linkChecker Check links with lychee 2026-08-28T12:50:38.6535297Z
+linkChecker Check links with lychee 2026-08-28T12:50:38.6535483Z ### Errors in dev/log/issues/298/pulls/299/CI-CD-BEST-PRACTICES.md
+linkChecker Check links with lychee 2026-08-28T12:50:38.6535757Z
+linkChecker Check links with lychee 2026-08-28T12:50:38.6536377Z * [ERROR] (at 437:3) | File not found. Check if file exists and path is correct
+linkChecker Check links with lychee 2026-08-28T12:50:38.6537739Z * [ERROR] (at 1:102) | File not found. Check if file exists and path is correct
+linkChecker Check links with lychee 2026-08-28T12:50:38.6539091Z * [ERROR] (at 1:137) | File not found. Check if file exists and path is correct
+linkChecker Check links with lychee 2026-08-28T12:50:38.6540502Z * [ERROR] (at 1:67) | File not found. Check if file exists and path is correct
+linkChecker Check links with lychee 2026-08-28T12:50:38.6541796Z * [ERROR] (at 436:3) | File not found. Check if file exists and path is correct
+linkChecker Check links with lychee 2026-08-28T12:50:38.6542400Z
+linkChecker Check links with lychee 2026-08-28T12:50:38.6542409Z
+linkChecker Check links with lychee 2026-08-28T12:50:38.6561410Z ##[notice]Summary report available at: https://github.com/link-foundation/links-notation/actions/runs/33172748012#summary-98853657524
+linkChecker Check links with lychee 2026-08-28T12:50:38.6629088Z ##[error]Process completed with exit code 2.
+linkChecker Check links with lychee 2026-08-28T12:50:38.6647984Z ##[end-action id=__lycheeverse_lychee-action.run-lychee;outcome=failure;conclusion=failure;duration_ms=5808]
diff --git a/dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-failed.log b/dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-failed.log
new file mode 100644
index 00000000..4f41d7a3
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-failed.log
@@ -0,0 +1,45 @@
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7026208Z ##[group]Run set -euo pipefail
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7026571Z ^[[36;1mset -euo pipefail^[[0m
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7026902Z ^[[36;1mfor attempt in $(seq 1 20); do^[[0m
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7027435Z ^[[36;1m if curl -fsS "https://crates.io/api/v1/crates/${PACKAGE_NAME}/${PACKAGE_VERSION}" >/dev/null 2>&1; then^[[0m
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7028142Z ^[[36;1m echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on crates.io (attempt ${attempt})"^[[0m
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7028591Z ^[[36;1m exit 0^[[0m
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7028821Z ^[[36;1m fi^[[0m
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7029464Z ^[[36;1m echo "Not visible yet, retrying in 15s (attempt ${attempt}/20)"^[[0m
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7029844Z ^[[36;1m sleep 15^[[0m
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7030063Z ^[[36;1mdone^[[0m
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7030465Z ^[[36;1mecho "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on crates.io within 5 minutes"^[[0m
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7030922Z ^[[36;1mexit 1^[[0m
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7067047Z shell: /usr/bin/bash -e {0}
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7067311Z env:
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7070645Z GITHUB_TOKEN: ***
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7070902Z CI_VERBOSE: false
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7071129Z CARGO_HOME: /home/runner/.cargo
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7071395Z CARGO_INCREMENTAL: 0
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7071620Z CARGO_TERM_COLOR: always
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7071856Z CACHE_ON_FAILURE: false
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7072105Z PACKAGE_NAME: links-notation
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7072357Z PACKAGE_VERSION: 0.16.0
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7072586Z ##[endgroup]
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7617783Z Not visible yet, retrying in 15s (attempt 1/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:24.8094806Z Not visible yet, retrying in 15s (attempt 2/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:39.8551976Z Not visible yet, retrying in 15s (attempt 3/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:54.9000478Z Not visible yet, retrying in 15s (attempt 4/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:51:09.9637544Z Not visible yet, retrying in 15s (attempt 5/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:51:25.0148386Z Not visible yet, retrying in 15s (attempt 6/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:51:40.0593201Z Not visible yet, retrying in 15s (attempt 7/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:51:55.1045223Z Not visible yet, retrying in 15s (attempt 8/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:52:10.1636203Z Not visible yet, retrying in 15s (attempt 9/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:52:25.2064098Z Not visible yet, retrying in 15s (attempt 10/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:52:40.4184624Z Not visible yet, retrying in 15s (attempt 11/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:52:55.4649465Z Not visible yet, retrying in 15s (attempt 12/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:53:10.5174684Z Not visible yet, retrying in 15s (attempt 13/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:53:25.5640997Z Not visible yet, retrying in 15s (attempt 14/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:53:40.6338362Z Not visible yet, retrying in 15s (attempt 15/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:53:55.6793835Z Not visible yet, retrying in 15s (attempt 16/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:54:10.7474875Z Not visible yet, retrying in 15s (attempt 17/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:54:25.7940404Z Not visible yet, retrying in 15s (attempt 18/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:54:40.8498536Z Not visible yet, retrying in 15s (attempt 19/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:54:55.8946448Z Not visible yet, retrying in 15s (attempt 20/20)
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:55:10.8969926Z ##[error]links-notation@0.16.0 did not appear on crates.io within 5 minutes
+publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:55:10.8973343Z ##[error]Process completed with exit code 1.
diff --git a/dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-publishToCratesIO.log b/dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-publishToCratesIO.log
new file mode 100644
index 00000000..6b94d7d3
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-publishToCratesIO.log
@@ -0,0 +1,612 @@
+2026-08-28T11:49:47.3653029Z Current runner version: '2.336.0'
+2026-08-28T11:49:47.3679778Z ##[group]Runner Image Provisioner
+2026-08-28T11:49:47.3680885Z Hosted Compute Agent
+2026-08-28T11:49:47.3681559Z Version: 20260819.586
+2026-08-28T11:49:47.3682314Z Commit: 3cc4a88dfa507ef76119ad1bb3eccc6378bb2b76
+2026-08-28T11:49:47.3683165Z Build Date: 2026-08-18T23:20:18Z
+2026-08-28T11:49:47.3683924Z Worker ID: {44f5a7b2-9297-4887-a4ff-dd2b838c5f0b}
+2026-08-28T11:49:47.3684789Z Azure Region: westus
+2026-08-28T11:49:47.3685426Z ##[endgroup]
+2026-08-28T11:49:47.3687075Z ##[group]Operating System
+2026-08-28T11:49:47.3687798Z Ubuntu
+2026-08-28T11:49:47.3688331Z 24.04.4
+2026-08-28T11:49:47.3688904Z LTS
+2026-08-28T11:49:47.3689771Z ##[endgroup]
+2026-08-28T11:49:47.3690478Z ##[group]Runner Image
+2026-08-28T11:49:47.3691198Z Image: ubuntu-24.04
+2026-08-28T11:49:47.3691845Z Version: 20260823.283.1
+2026-08-28T11:49:47.3693237Z Included Software: https://github.com/actions/runner-images/blob/ubuntu24/20260823.283/images/ubuntu/Ubuntu2404-Readme.md
+2026-08-28T11:49:47.3694903Z Image Release: https://github.com/actions/runner-images/releases/tag/ubuntu24%2F20260823.283
+2026-08-28T11:49:47.3695999Z ##[endgroup]
+2026-08-28T11:49:47.3697225Z ##[group]GITHUB_TOKEN Permissions
+2026-08-28T11:49:47.3700017Z Contents: read
+2026-08-28T11:49:47.3700749Z Metadata: read
+2026-08-28T11:49:47.3701540Z ##[endgroup]
+2026-08-28T11:49:47.3703753Z Secret source: Actions
+2026-08-28T11:49:47.3705096Z Prepare workflow directory
+2026-08-28T11:49:47.4283820Z Prepare all required actions
+2026-08-28T11:49:47.4331904Z Getting action download info
+2026-08-28T11:49:47.7997139Z Download action repository 'actions/checkout@v7' (SHA:3d3c42e5aac5ba805825da76410c181273ba90b1)
+2026-08-28T11:49:47.9180400Z Download action repository 'dtolnay/rust-toolchain@stable' (SHA:4360b52568e2003a75bf9bc1d59f33a8e3fc893c)
+2026-08-28T11:49:48.1376511Z Download action repository 'Swatinem/rust-cache@v2' (SHA:6323deb102c322ba6fcbdcafc7e3dddab59af2b6)
+2026-08-28T11:49:48.5438980Z Download action repository 'rust-lang/crates-io-auth-action@v1' (SHA:c6f97d42243bad5fab37ca0427f495c86d5b1a18)
+2026-08-28T11:49:48.9507110Z Complete job name: publishToCratesIO
+2026-08-28T11:49:49.0385421Z ##[group]Run actions/checkout@v7
+2026-08-28T11:49:49.0387032Z with:
+2026-08-28T11:49:49.0388078Z persist-credentials: false
+2026-08-28T11:49:49.0389492Z submodules: true
+2026-08-28T11:49:49.0390644Z repository: link-foundation/links-notation
+2026-08-28T11:49:49.0400519Z token: ***
+2026-08-28T11:49:49.0401495Z ssh-strict: true
+2026-08-28T11:49:49.0402519Z ssh-user: git
+2026-08-28T11:49:49.0403476Z clean: true
+2026-08-28T11:49:49.0404546Z sparse-checkout-cone-mode: true
+2026-08-28T11:49:49.0405748Z fetch-depth: 1
+2026-08-28T11:49:49.0406742Z fetch-tags: false
+2026-08-28T11:49:49.0407763Z show-progress: true
+2026-08-28T11:49:49.0408808Z lfs: false
+2026-08-28T11:49:49.0409968Z set-safe-directory: true
+2026-08-28T11:49:49.0411145Z allow-unsafe-pr-checkout: false
+2026-08-28T11:49:49.0412635Z env:
+2026-08-28T11:49:49.0422298Z GITHUB_TOKEN: ***
+2026-08-28T11:49:49.0423320Z CI_VERBOSE: false
+2026-08-28T11:49:49.0424322Z ##[endgroup]
+2026-08-28T11:49:49.1481809Z Syncing repository: link-foundation/links-notation
+2026-08-28T11:49:49.1486600Z ##[group]Getting Git version info
+2026-08-28T11:49:49.1489648Z Working directory is '/home/runner/work/links-notation/links-notation'
+2026-08-28T11:49:49.1493551Z [command]/usr/bin/git version
+2026-08-28T11:49:49.1558169Z git version 2.55.0
+2026-08-28T11:49:49.1631650Z ##[endgroup]
+2026-08-28T11:49:49.1639953Z Temporarily overriding HOME='/home/runner/work/_temp/470ce371-7a3e-4201-b88e-730fb838ab60' before making global git config changes
+2026-08-28T11:49:49.1643515Z Adding repository directory to the temporary git global config as a safe directory
+2026-08-28T11:49:49.1647246Z [command]/usr/bin/git config --global --add safe.directory /home/runner/work/links-notation/links-notation
+2026-08-28T11:49:49.1657909Z Deleting the contents of '/home/runner/work/links-notation/links-notation'
+2026-08-28T11:49:49.1663197Z ##[group]Determining repository object format
+2026-08-28T11:49:49.1666742Z ##[endgroup]
+2026-08-28T11:49:49.1669809Z ##[group]Initializing the repository
+2026-08-28T11:49:49.1671714Z [command]/usr/bin/git init /home/runner/work/links-notation/links-notation
+2026-08-28T11:49:49.1781935Z hint: Using 'master' as the name for the initial branch. This default branch name
+2026-08-28T11:49:49.1785673Z hint: will change to "main" in Git 3.0. To configure the initial branch name
+2026-08-28T11:49:49.1789748Z hint: to use in all of your new repositories, which will suppress this warning,
+2026-08-28T11:49:49.1792715Z hint: call:
+2026-08-28T11:49:49.1794225Z hint:
+2026-08-28T11:49:49.1796231Z hint: git config --global init.defaultBranch
+2026-08-28T11:49:49.1798639Z hint:
+2026-08-28T11:49:49.1801177Z hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
+2026-08-28T11:49:49.1804694Z hint: 'development'. The just-created branch can be renamed via this command:
+2026-08-28T11:49:49.1807417Z hint:
+2026-08-28T11:49:49.1809521Z hint: git branch -m
+2026-08-28T11:49:49.1810901Z hint:
+2026-08-28T11:49:49.1812283Z hint: Disable this message with "git config set advice.defaultBranchName false"
+2026-08-28T11:49:49.1814671Z Initialized empty Git repository in /home/runner/work/links-notation/links-notation/.git/
+2026-08-28T11:49:49.1819962Z [command]/usr/bin/git remote add origin https://github.com/link-foundation/links-notation
+2026-08-28T11:49:49.1858594Z ##[endgroup]
+2026-08-28T11:49:49.1862145Z ##[group]Disabling automatic garbage collection
+2026-08-28T11:49:49.1865019Z [command]/usr/bin/git config --local gc.auto 0
+2026-08-28T11:49:49.1901658Z ##[endgroup]
+2026-08-28T11:49:49.1904257Z ##[group]Setting up auth
+2026-08-28T11:49:49.1906296Z Removing SSH command configuration
+2026-08-28T11:49:49.1909302Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
+2026-08-28T11:49:49.1947559Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
+2026-08-28T11:49:49.2321611Z Removing HTTP extra header
+2026-08-28T11:49:49.2324872Z [command]/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader
+2026-08-28T11:49:49.2368991Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :"
+2026-08-28T11:49:49.2593110Z Removing includeIf entries pointing to credentials config files
+2026-08-28T11:49:49.2606002Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
+2026-08-28T11:49:49.2644701Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
+2026-08-28T11:49:49.2876889Z [command]/usr/bin/git config --file /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config http.https://github.com/.extraheader AUTHORIZATION: basic ***
+2026-08-28T11:49:49.2919584Z [command]/usr/bin/git config --local includeIf.gitdir:/home/runner/work/links-notation/links-notation/.git.path /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config
+2026-08-28T11:49:49.2954907Z [command]/usr/bin/git config --local includeIf.gitdir:/home/runner/work/links-notation/links-notation/.git/worktrees/*.path /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config
+2026-08-28T11:49:49.2993390Z [command]/usr/bin/git config --local includeIf.gitdir:/github/workspace/.git.path /github/runner_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config
+2026-08-28T11:49:49.3031741Z [command]/usr/bin/git config --local includeIf.gitdir:/github/workspace/.git/worktrees/*.path /github/runner_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config
+2026-08-28T11:49:49.3063812Z ##[endgroup]
+2026-08-28T11:49:49.3065564Z ##[group]Fetching the repository
+2026-08-28T11:49:49.3070907Z [command]/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +2b829f37b8369ff5a4b5114923eae4d49f03c78d:refs/remotes/origin/main
+2026-08-28T11:49:49.9307999Z From https://github.com/link-foundation/links-notation
+2026-08-28T11:49:49.9311010Z * [new ref] 2b829f37b8369ff5a4b5114923eae4d49f03c78d -> origin/main
+2026-08-28T11:49:49.9319527Z [command]/usr/bin/git branch --list --remote origin/main
+2026-08-28T11:49:49.9351115Z origin/main
+2026-08-28T11:49:49.9361546Z [command]/usr/bin/git rev-parse refs/remotes/origin/main
+2026-08-28T11:49:49.9390519Z 2b829f37b8369ff5a4b5114923eae4d49f03c78d
+2026-08-28T11:49:49.9396153Z ##[endgroup]
+2026-08-28T11:49:49.9397649Z ##[group]Determining the checkout info
+2026-08-28T11:49:49.9399671Z ##[endgroup]
+2026-08-28T11:49:49.9403734Z [command]/usr/bin/git sparse-checkout disable
+2026-08-28T11:49:49.9462825Z [command]/usr/bin/git config --local --unset-all extensions.worktreeConfig
+2026-08-28T11:49:49.9498405Z ##[group]Checking out the ref
+2026-08-28T11:49:49.9501792Z [command]/usr/bin/git checkout --progress --force -B main refs/remotes/origin/main
+2026-08-28T11:49:49.9962572Z Switched to a new branch 'main'
+2026-08-28T11:49:49.9972794Z branch 'main' set up to track 'origin/main'.
+2026-08-28T11:49:49.9980835Z ##[endgroup]
+2026-08-28T11:49:49.9983268Z ##[group]Setting up auth for fetching submodules
+2026-08-28T11:49:49.9990175Z [command]/usr/bin/git config --file /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config http.https://github.com/.extraheader AUTHORIZATION: basic ***
+2026-08-28T11:49:50.0041145Z [command]/usr/bin/git config --global include.path /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config
+2026-08-28T11:49:50.0078724Z [command]/usr/bin/git config --global --unset-all url.https://github.com/.insteadOf
+2026-08-28T11:49:50.0122629Z [command]/usr/bin/git config --global --add url.https://github.com/.insteadOf git@github.com:
+2026-08-28T11:49:50.0160579Z [command]/usr/bin/git config --global --add url.https://github.com/.insteadOf org-176174013@github.com:
+2026-08-28T11:49:50.0195394Z ##[endgroup]
+2026-08-28T11:49:50.0196866Z ##[group]Fetching submodules
+2026-08-28T11:49:50.0199826Z [command]/usr/bin/git submodule sync
+2026-08-28T11:49:50.0465793Z [command]/usr/bin/git -c protocol.version=2 submodule update --init --force --depth=1
+2026-08-28T11:49:50.0730815Z [command]/usr/bin/git submodule foreach git config --local gc.auto 0
+2026-08-28T11:49:50.0963570Z ##[endgroup]
+2026-08-28T11:49:50.1010399Z [command]/usr/bin/git log -1 --format=%H
+2026-08-28T11:49:50.1044561Z 2b829f37b8369ff5a4b5114923eae4d49f03c78d
+2026-08-28T11:49:50.1054694Z ##[group]Removing auth
+2026-08-28T11:49:50.1055383Z Removing SSH command configuration
+2026-08-28T11:49:50.1059826Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
+2026-08-28T11:49:50.1096704Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
+2026-08-28T11:49:50.1367330Z Removing HTTP extra header
+2026-08-28T11:49:50.1368048Z [command]/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader
+2026-08-28T11:49:50.1418896Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :"
+2026-08-28T11:49:50.1654955Z Removing includeIf entries pointing to credentials config files
+2026-08-28T11:49:50.1658165Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
+2026-08-28T11:49:50.1691441Z includeif.gitdir:/home/runner/work/links-notation/links-notation/.git.path
+2026-08-28T11:49:50.1692338Z includeif.gitdir:/home/runner/work/links-notation/links-notation/.git/worktrees/*.path
+2026-08-28T11:49:50.1693498Z includeif.gitdir:/github/workspace/.git.path
+2026-08-28T11:49:50.1694011Z includeif.gitdir:/github/workspace/.git/worktrees/*.path
+2026-08-28T11:49:50.1701092Z [command]/usr/bin/git config --local --get-all includeif.gitdir:/home/runner/work/links-notation/links-notation/.git.path
+2026-08-28T11:49:50.1728532Z /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config
+2026-08-28T11:49:50.1739840Z [command]/usr/bin/git config --local --unset includeif.gitdir:/home/runner/work/links-notation/links-notation/.git.path \/home\/runner\/work\/_temp\/git\-credentials\-55a39926\-baa1\-4a6f\-844d\-ff2094c42b53\.config
+2026-08-28T11:49:50.1781410Z [command]/usr/bin/git config --local --get-all includeif.gitdir:/home/runner/work/links-notation/links-notation/.git/worktrees/*.path
+2026-08-28T11:49:50.1812109Z /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config
+2026-08-28T11:49:50.1820225Z [command]/usr/bin/git config --local --unset includeif.gitdir:/home/runner/work/links-notation/links-notation/.git/worktrees/*.path \/home\/runner\/work\/_temp\/git\-credentials\-55a39926\-baa1\-4a6f\-844d\-ff2094c42b53\.config
+2026-08-28T11:49:50.1858283Z [command]/usr/bin/git config --local --get-all includeif.gitdir:/github/workspace/.git.path
+2026-08-28T11:49:50.1885665Z /github/runner_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config
+2026-08-28T11:49:50.1893145Z [command]/usr/bin/git config --local --unset includeif.gitdir:/github/workspace/.git.path \/github\/runner_temp\/git\-credentials\-55a39926\-baa1\-4a6f\-844d\-ff2094c42b53\.config
+2026-08-28T11:49:50.1930324Z [command]/usr/bin/git config --local --get-all includeif.gitdir:/github/workspace/.git/worktrees/*.path
+2026-08-28T11:49:50.1959306Z /github/runner_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config
+2026-08-28T11:49:50.1966462Z [command]/usr/bin/git config --local --unset includeif.gitdir:/github/workspace/.git/worktrees/*.path \/github\/runner_temp\/git\-credentials\-55a39926\-baa1\-4a6f\-844d\-ff2094c42b53\.config
+2026-08-28T11:49:50.2003089Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
+2026-08-28T11:49:50.2232827Z Removing credentials config '/home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config'
+2026-08-28T11:49:50.2242109Z ##[endgroup]
+2026-08-28T11:49:50.2582566Z ##[group]Run dtolnay/rust-toolchain@stable
+2026-08-28T11:49:50.2583012Z with:
+2026-08-28T11:49:50.2583291Z toolchain: stable
+2026-08-28T11:49:50.2583586Z env:
+2026-08-28T11:49:50.2586595Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.2586919Z CI_VERBOSE: false
+2026-08-28T11:49:50.2587207Z ##[endgroup]
+2026-08-28T11:49:50.2652914Z ##[start-action display=Parse toolchain version;id=__dtolnay_rust-toolchain.parse]
+2026-08-28T11:49:50.2713943Z ##[group]Run if [[ -z $toolchain ]]; then
+2026-08-28T11:49:50.2714500Z [36;1mif [[ -z $toolchain ]]; then[0m
+2026-08-28T11:49:50.2715211Z [36;1m # GitHub does not enforce `required: true` inputs itself. https://github.com/actions/runner/issues/1070[0m
+2026-08-28T11:49:50.2715975Z [36;1m echo "'toolchain' is a required input" >&2[0m
+2026-08-28T11:49:50.2716408Z [36;1m exit 1[0m
+2026-08-28T11:49:50.2716873Z [36;1melif [[ $toolchain =~ ^stable' '[0-9]+' '(year|month|week|day)s?' 'ago$ ]]; then[0m
+2026-08-28T11:49:50.2717475Z [36;1m if [[ Linux == macOS ]]; then[0m
+2026-08-28T11:49:50.2718166Z [36;1m echo "toolchain=1.$((($(date -v-$(sed 's/stable \([0-9]*\) \(.\).*/\1\2/' <<< $toolchain) +%s)/60/60/24-16569)/7/6))" >> $GITHUB_OUTPUT[0m
+2026-08-28T11:49:50.2718841Z [36;1m else[0m
+2026-08-28T11:49:50.2719587Z [36;1m echo "toolchain=1.$((($(date --date "${toolchain#stable }" +%s)/60/60/24-16569)/7/6))" >> $GITHUB_OUTPUT[0m
+2026-08-28T11:49:50.2720211Z [36;1m fi[0m
+2026-08-28T11:49:50.2720692Z [36;1melif [[ $toolchain =~ ^stable' 'minus' '[0-9]+' 'releases?$ ]]; then[0m
+2026-08-28T11:49:50.2721379Z [36;1m echo "toolchain=1.$((($(date +%s)/60/60/24-16569)/7/6-${toolchain//[^0-9]/}))" >> $GITHUB_OUTPUT[0m
+2026-08-28T11:49:50.2722213Z [36;1melif [[ $toolchain =~ ^1\.[0-9]+$ ]]; then[0m
+2026-08-28T11:49:50.2722917Z [36;1m echo "toolchain=1.$((i=${toolchain#1.}, c=($(date +%s)/60/60/24-16569)/7/6, i+9*i*(10*i<=c)+90*i*(100*i<=c)))" >> $GITHUB_OUTPUT[0m
+2026-08-28T11:49:50.2723584Z [36;1melse[0m
+2026-08-28T11:49:50.2723973Z [36;1m echo "toolchain=$toolchain" >> $GITHUB_OUTPUT[0m
+2026-08-28T11:49:50.2724405Z [36;1mfi[0m
+2026-08-28T11:49:50.2767489Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+2026-08-28T11:49:50.2767992Z env:
+2026-08-28T11:49:50.2771353Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.2771705Z CI_VERBOSE: false
+2026-08-28T11:49:50.2772029Z toolchain: stable
+2026-08-28T11:49:50.2772356Z ##[endgroup]
+2026-08-28T11:49:50.2889754Z ##[end-action id=__dtolnay_rust-toolchain.parse;outcome=success;conclusion=success;duration_ms=23]
+2026-08-28T11:49:50.2896784Z ##[start-action display=Construct rustup command line;id=__dtolnay_rust-toolchain.flags]
+2026-08-28T11:49:50.2928092Z ##[group]Run echo "targets=$(for t in ${targets//,/ }; do echo -n ' --target' $t; done)" >> $GITHUB_OUTPUT
+2026-08-28T11:49:50.2928923Z [36;1mecho "targets=$(for t in ${targets//,/ }; do echo -n ' --target' $t; done)" >> $GITHUB_OUTPUT[0m
+2026-08-28T11:49:50.2930013Z [36;1mecho "components=$(for c in ${components//,/ }; do echo -n ' --component' $c; done)" >> $GITHUB_OUTPUT[0m
+2026-08-28T11:49:50.2930670Z [36;1mecho "downgrade=" >> $GITHUB_OUTPUT[0m
+2026-08-28T11:49:50.2968103Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+2026-08-28T11:49:50.2968582Z env:
+2026-08-28T11:49:50.2971765Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.2972123Z CI_VERBOSE: false
+2026-08-28T11:49:50.2972445Z targets:
+2026-08-28T11:49:50.2972744Z components:
+2026-08-28T11:49:50.2973058Z ##[endgroup]
+2026-08-28T11:49:50.3048787Z ##[end-action id=__dtolnay_rust-toolchain.flags;outcome=success;conclusion=success;duration_ms=15]
+2026-08-28T11:49:50.3052044Z ##[start-action display=Set $CARGO_HOME;id=__dtolnay_rust-toolchain.__run]
+2026-08-28T11:49:50.3077361Z ##[group]Run echo CARGO_HOME=${CARGO_HOME:-"$HOME/.cargo"} >> $GITHUB_ENV
+2026-08-28T11:49:50.3078010Z [36;1mecho CARGO_HOME=${CARGO_HOME:-"$HOME/.cargo"} >> $GITHUB_ENV[0m
+2026-08-28T11:49:50.3115334Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+2026-08-28T11:49:50.3116021Z env:
+2026-08-28T11:49:50.3119342Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.3119713Z CI_VERBOSE: false
+2026-08-28T11:49:50.3120042Z ##[endgroup]
+2026-08-28T11:49:50.3191212Z ##[end-action id=__dtolnay_rust-toolchain.__run;outcome=success;conclusion=success;duration_ms=13]
+2026-08-28T11:49:50.3194017Z ##[start-action display=Install rustup if needed;id=__dtolnay_rust-toolchain.__run_2]
+2026-08-28T11:49:50.3224668Z ##[group]Run if ! command -v rustup &>/dev/null; then
+2026-08-28T11:49:50.3225177Z [36;1mif ! command -v rustup &>/dev/null; then[0m
+2026-08-28T11:49:50.3226123Z [36;1m curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused --location --silent --show-error --fail https://sh.rustup.rs | sh -s -- --default-toolchain none -y[0m
+2026-08-28T11:49:50.3227088Z [36;1m echo "$CARGO_HOME/bin" >> $GITHUB_PATH[0m
+2026-08-28T11:49:50.3227489Z [36;1mfi[0m
+2026-08-28T11:49:50.3266655Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+2026-08-28T11:49:50.3267127Z env:
+2026-08-28T11:49:50.3270577Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.3270935Z CI_VERBOSE: false
+2026-08-28T11:49:50.3271271Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:49:50.3271633Z ##[endgroup]
+2026-08-28T11:49:50.3341288Z ##[end-action id=__dtolnay_rust-toolchain.__run_2;outcome=success;conclusion=success;duration_ms=14]
+2026-08-28T11:49:50.3344123Z ##[start-action display=Install rustup if needed on windows;id=__dtolnay_rust-toolchain.__run_3]
+2026-08-28T11:49:50.3352603Z ##[end-action id=__dtolnay_rust-toolchain.__run_3;outcome=skipped;conclusion=skipped;duration_ms=0]
+2026-08-28T11:49:50.3365014Z ##[start-action display=rustup toolchain install stable;id=__dtolnay_rust-toolchain.__run_4]
+2026-08-28T11:49:50.3477483Z ##[group]Run rustup toolchain install stable --profile minimal --no-self-update
+2026-08-28T11:49:50.3478818Z [36;1mrustup toolchain install stable --profile minimal --no-self-update[0m
+2026-08-28T11:49:50.3521226Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+2026-08-28T11:49:50.3521719Z env:
+2026-08-28T11:49:50.3524773Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.3525106Z CI_VERBOSE: false
+2026-08-28T11:49:50.3525441Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:49:50.3525821Z RUSTUP_PERMIT_COPY_RENAME: 1
+2026-08-28T11:49:50.3526177Z ##[endgroup]
+2026-08-28T11:49:50.5613749Z info: syncing channel updates for stable-x86_64-unknown-linux-gnu
+2026-08-28T11:49:50.6382308Z
+2026-08-28T11:49:50.6468163Z stable-x86_64-unknown-linux-gnu unchanged - rustc 1.98.0 (88d9e12ae 2026-08-18)
+2026-08-28T11:49:50.6468769Z
+2026-08-28T11:49:50.6501024Z ##[end-action id=__dtolnay_rust-toolchain.__run_4;outcome=success;conclusion=success;duration_ms=313]
+2026-08-28T11:49:50.6507424Z ##[start-action display=rustup default stable;id=__dtolnay_rust-toolchain.__run_5]
+2026-08-28T11:49:50.6537024Z ##[group]Run rustup default stable
+2026-08-28T11:49:50.6537446Z [36;1mrustup default stable[0m
+2026-08-28T11:49:50.6580117Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+2026-08-28T11:49:50.6580670Z env:
+2026-08-28T11:49:50.6583794Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.6584129Z CI_VERBOSE: false
+2026-08-28T11:49:50.6584470Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:49:50.6584828Z ##[endgroup]
+2026-08-28T11:49:50.6705548Z info: using existing install for stable-x86_64-unknown-linux-gnu
+2026-08-28T11:49:50.6713832Z info: default toolchain set to stable-x86_64-unknown-linux-gnu
+2026-08-28T11:49:50.6714261Z
+2026-08-28T11:49:50.6795800Z stable-x86_64-unknown-linux-gnu unchanged - rustc 1.98.0 (88d9e12ae 2026-08-18)
+2026-08-28T11:49:50.6796559Z
+2026-08-28T11:49:50.6813154Z ##[end-action id=__dtolnay_rust-toolchain.__run_5;outcome=success;conclusion=success;duration_ms=30]
+2026-08-28T11:49:50.6816324Z ##[start-action display=Create cachekey;id=__dtolnay_rust-toolchain.rustc-version]
+2026-08-28T11:49:50.6845809Z ##[group]Run DATE=$(rustc +stable --version --verbose | sed -ne 's/^commit-date: \(20[0-9][0-9]\)-\([01][0-9]\)-\([0-3][0-9]\)$/\1\2\3/p')
+2026-08-28T11:49:50.6847050Z [36;1mDATE=$(rustc +stable --version --verbose | sed -ne 's/^commit-date: \(20[0-9][0-9]\)-\([01][0-9]\)-\([0-3][0-9]\)$/\1\2\3/p')[0m
+2026-08-28T11:49:50.6847878Z [36;1mHASH=$(rustc +stable --version --verbose | sed -ne 's/^commit-hash: //p')[0m
+2026-08-28T11:49:50.6848536Z [36;1mecho "cachekey=$(echo $DATE$HASH | head -c12)" >> $GITHUB_OUTPUT[0m
+2026-08-28T11:49:50.6890668Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+2026-08-28T11:49:50.6891143Z env:
+2026-08-28T11:49:50.6894163Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.6894496Z CI_VERBOSE: false
+2026-08-28T11:49:50.6894885Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:49:50.6895272Z ##[endgroup]
+2026-08-28T11:49:50.7286991Z ##[end-action id=__dtolnay_rust-toolchain.rustc-version;outcome=success;conclusion=success;duration_ms=46]
+2026-08-28T11:49:50.7290713Z ##[start-action display=Disable incremental compilation;id=__dtolnay_rust-toolchain.__run_6]
+2026-08-28T11:49:50.7317653Z ##[group]Run if [ -z "${CARGO_INCREMENTAL+set}" ]; then
+2026-08-28T11:49:50.7318199Z [36;1mif [ -z "${CARGO_INCREMENTAL+set}" ]; then[0m
+2026-08-28T11:49:50.7318672Z [36;1m echo CARGO_INCREMENTAL=0 >> $GITHUB_ENV[0m
+2026-08-28T11:49:50.7319360Z [36;1mfi[0m
+2026-08-28T11:49:50.7361574Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+2026-08-28T11:49:50.7362114Z env:
+2026-08-28T11:49:50.7365149Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.7365492Z CI_VERBOSE: false
+2026-08-28T11:49:50.7365834Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:49:50.7366208Z ##[endgroup]
+2026-08-28T11:49:50.7437074Z ##[end-action id=__dtolnay_rust-toolchain.__run_6;outcome=success;conclusion=success;duration_ms=14]
+2026-08-28T11:49:50.7440451Z ##[start-action display=Enable colors in Cargo output;id=__dtolnay_rust-toolchain.__run_7]
+2026-08-28T11:49:50.7468582Z ##[group]Run if [ -z "${CARGO_TERM_COLOR+set}" ]; then
+2026-08-28T11:49:50.7469396Z [36;1mif [ -z "${CARGO_TERM_COLOR+set}" ]; then[0m
+2026-08-28T11:49:50.7469890Z [36;1m echo CARGO_TERM_COLOR=always >> $GITHUB_ENV[0m
+2026-08-28T11:49:50.7470314Z [36;1mfi[0m
+2026-08-28T11:49:50.7511045Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+2026-08-28T11:49:50.7511514Z env:
+2026-08-28T11:49:50.7514524Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.7514852Z CI_VERBOSE: false
+2026-08-28T11:49:50.7515188Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:49:50.7515556Z CARGO_INCREMENTAL: 0
+2026-08-28T11:49:50.7515885Z ##[endgroup]
+2026-08-28T11:49:50.7585857Z ##[end-action id=__dtolnay_rust-toolchain.__run_7;outcome=success;conclusion=success;duration_ms=14]
+2026-08-28T11:49:50.7588639Z ##[start-action display=Enable Cargo sparse registry;id=__dtolnay_rust-toolchain.__run_8]
+2026-08-28T11:49:50.7616521Z ##[group]Run # implemented in 1.66, stabilized in 1.68, made default in 1.70
+2026-08-28T11:49:50.7617163Z [36;1m# implemented in 1.66, stabilized in 1.68, made default in 1.70[0m
+2026-08-28T11:49:50.7618047Z [36;1mif [ -z "${CARGO_REGISTRIES_CRATES_IO_PROTOCOL+set}" -o -f "/home/runner/work/_temp"/.implicit_cargo_registries_crates_io_protocol ]; then[0m
+2026-08-28T11:49:50.7618927Z [36;1m if rustc +stable --version --verbose | grep -q '^release: 1\.6[89]\.'; then[0m
+2026-08-28T11:49:50.7620145Z [36;1m touch "/home/runner/work/_temp"/.implicit_cargo_registries_crates_io_protocol || true[0m
+2026-08-28T11:49:50.7620840Z [36;1m echo CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse >> $GITHUB_ENV[0m
+2026-08-28T11:49:50.7621482Z [36;1m elif rustc +stable --version --verbose | grep -q '^release: 1\.6[67]\.'; then[0m
+2026-08-28T11:49:50.7622220Z [36;1m touch "/home/runner/work/_temp"/.implicit_cargo_registries_crates_io_protocol || true[0m
+2026-08-28T11:49:50.7622895Z [36;1m echo CARGO_REGISTRIES_CRATES_IO_PROTOCOL=git >> $GITHUB_ENV[0m
+2026-08-28T11:49:50.7623359Z [36;1m fi[0m
+2026-08-28T11:49:50.7623650Z [36;1mfi[0m
+2026-08-28T11:49:50.7663638Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+2026-08-28T11:49:50.7664099Z env:
+2026-08-28T11:49:50.7667192Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.7667695Z CI_VERBOSE: false
+2026-08-28T11:49:50.7668033Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:49:50.7668396Z CARGO_INCREMENTAL: 0
+2026-08-28T11:49:50.7668738Z CARGO_TERM_COLOR: always
+2026-08-28T11:49:50.7669365Z ##[endgroup]
+2026-08-28T11:49:50.8020469Z ##[end-action id=__dtolnay_rust-toolchain.__run_8;outcome=success;conclusion=success;duration_ms=43]
+2026-08-28T11:49:50.8023869Z ##[start-action display=Work around spurious network errors in curl 8.0;id=__dtolnay_rust-toolchain.__run_9]
+2026-08-28T11:49:50.8053113Z ##[group]Run # https://rust-lang.zulipchat.com/#narrow/stream/246057-t-cargo/topic/timeout.20investigation
+2026-08-28T11:49:50.8054051Z [36;1m# https://rust-lang.zulipchat.com/#narrow/stream/246057-t-cargo/topic/timeout.20investigation[0m
+2026-08-28T11:49:50.8054801Z [36;1mif rustc +stable --version --verbose | grep -q '^release: 1\.7[01]\.'; then[0m
+2026-08-28T11:49:50.8055399Z [36;1m echo CARGO_HTTP_MULTIPLEXING=false >> $GITHUB_ENV[0m
+2026-08-28T11:49:50.8055824Z [36;1mfi[0m
+2026-08-28T11:49:50.8097650Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+2026-08-28T11:49:50.8098114Z env:
+2026-08-28T11:49:50.8101490Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.8101831Z CI_VERBOSE: false
+2026-08-28T11:49:50.8102163Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:49:50.8102530Z CARGO_INCREMENTAL: 0
+2026-08-28T11:49:50.8102860Z CARGO_TERM_COLOR: always
+2026-08-28T11:49:50.8103186Z ##[endgroup]
+2026-08-28T11:49:50.8311121Z ##[end-action id=__dtolnay_rust-toolchain.__run_9;outcome=success;conclusion=success;duration_ms=28]
+2026-08-28T11:49:50.8315804Z ##[start-action display=rustc +stable --version --verbose;id=__dtolnay_rust-toolchain.__run_10]
+2026-08-28T11:49:50.8343169Z ##[group]Run rustc +stable --version --verbose
+2026-08-28T11:49:50.8343634Z [36;1mrustc +stable --version --verbose[0m
+2026-08-28T11:49:50.8383544Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
+2026-08-28T11:49:50.8384008Z env:
+2026-08-28T11:49:50.8387283Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.8387643Z CI_VERBOSE: false
+2026-08-28T11:49:50.8387985Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:49:50.8388348Z CARGO_INCREMENTAL: 0
+2026-08-28T11:49:50.8388691Z CARGO_TERM_COLOR: always
+2026-08-28T11:49:50.8389474Z ##[endgroup]
+2026-08-28T11:49:50.8572772Z rustc 1.98.0 (88d9e12ae 2026-08-18)
+2026-08-28T11:49:50.8573738Z binary: rustc
+2026-08-28T11:49:50.8574289Z commit-hash: 88d9e12ae178fab0fb5cc050a94da85685d449ea
+2026-08-28T11:49:50.8574862Z commit-date: 2026-08-18
+2026-08-28T11:49:50.8575399Z host: x86_64-unknown-linux-gnu
+2026-08-28T11:49:50.8576250Z release: 1.98.0
+2026-08-28T11:49:50.8576977Z LLVM version: 22.1.8
+2026-08-28T11:49:50.8597175Z ##[end-action id=__dtolnay_rust-toolchain.__run_10;outcome=success;conclusion=success;duration_ms=28]
+2026-08-28T11:49:50.8740559Z ##[group]Run Swatinem/rust-cache@v2
+2026-08-28T11:49:50.8741000Z with:
+2026-08-28T11:49:50.8741294Z workspaces: rust
+2026-08-28T11:49:50.8741611Z prefix-key: v0-rust
+2026-08-28T11:49:50.8741949Z add-job-id-key: true
+2026-08-28T11:49:50.8742300Z add-rust-environment-hash-key: true
+2026-08-28T11:49:50.8742683Z cache-targets: true
+2026-08-28T11:49:50.8743022Z cache-all-crates: false
+2026-08-28T11:49:50.8743379Z cache-workspace-crates: false
+2026-08-28T11:49:50.8743727Z save-if: true
+2026-08-28T11:49:50.8744037Z cache-provider: github
+2026-08-28T11:49:50.8744365Z cache-bin: true
+2026-08-28T11:49:50.8744665Z lookup-only: false
+2026-08-28T11:49:50.8744972Z cmd-format: {0}
+2026-08-28T11:49:50.8745262Z env:
+2026-08-28T11:49:50.8748327Z GITHUB_TOKEN: ***
+2026-08-28T11:49:50.8748648Z CI_VERBOSE: false
+2026-08-28T11:49:50.8749242Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:49:50.8749674Z CARGO_INCREMENTAL: 0
+2026-08-28T11:49:50.8750056Z CARGO_TERM_COLOR: always
+2026-08-28T11:49:50.8750386Z ##[endgroup]
+2026-08-28T11:49:52.1708796Z ##[group]Cache Configuration
+2026-08-28T11:49:52.1709461Z Cache Provider:
+2026-08-28T11:49:52.1709954Z github
+2026-08-28T11:49:52.1710304Z Workspaces:
+2026-08-28T11:49:52.1710801Z /home/runner/work/links-notation/links-notation/rust
+2026-08-28T11:49:52.1711354Z Cache Paths:
+2026-08-28T11:49:52.1711721Z /home/runner/.cargo/bin
+2026-08-28T11:49:52.1712198Z /home/runner/.cargo/.crates.toml
+2026-08-28T11:49:52.1712730Z /home/runner/.cargo/.crates2.json
+2026-08-28T11:49:52.1713204Z /home/runner/.cargo/registry
+2026-08-28T11:49:52.1713645Z /home/runner/.cargo/git
+2026-08-28T11:49:52.1714202Z /home/runner/work/links-notation/links-notation/rust/target
+2026-08-28T11:49:52.1714791Z Restore Key:
+2026-08-28T11:49:52.1715240Z v0-rust-publishToCratesIO-Linux-x64-0b9fd15e
+2026-08-28T11:49:52.1715820Z Cache Key:
+2026-08-28T11:49:52.1716337Z v0-rust-publishToCratesIO-Linux-x64-0b9fd15e-2cd05178
+2026-08-28T11:49:52.1716935Z .. Prefix:
+2026-08-28T11:49:52.1717329Z - v0-rust-publishToCratesIO-Linux-x64
+2026-08-28T11:49:52.1717910Z .. Environment considered:
+2026-08-28T11:49:52.1718217Z - Rust Versions:
+2026-08-28T11:49:52.1718595Z - 1.98.0 x86_64-unknown-linux-gnu 88d9e12ae178fab0fb5cc050a94da85685d449ea
+2026-08-28T11:49:52.1719303Z - CARGO_HOME
+2026-08-28T11:49:52.1719634Z - CARGO_INCREMENTAL
+2026-08-28T11:49:52.1719885Z - CARGO_TERM_COLOR
+2026-08-28T11:49:52.1720129Z .. Lockfiles considered:
+2026-08-28T11:49:52.1720588Z - /home/runner/work/links-notation/links-notation/rust/links-notation-macro/Cargo.toml
+2026-08-28T11:49:52.1721274Z - /home/runner/work/links-notation/links-notation/rust/links-notation/Cargo.toml
+2026-08-28T11:49:52.1721972Z ##[endgroup]
+2026-08-28T11:49:52.1722161Z
+2026-08-28T11:49:52.1722298Z ... Restoring cache ...
+2026-08-28T11:49:52.4201150Z Cache hit for: v0-rust-publishToCratesIO-Linux-x64-0b9fd15e-2cd05178
+2026-08-28T11:49:53.6824112Z Received 632057 of 9020665 (7.0%), 0.6 MBs/sec
+2026-08-28T11:49:53.8205105Z Received 9020665 of 9020665 (100.0%), 7.6 MBs/sec
+2026-08-28T11:49:53.8205780Z Cache Size: ~9 MB (9020665 B)
+2026-08-28T11:49:53.8238686Z [command]/usr/bin/tar -xf /home/runner/work/_temp/563948bf-c720-428f-8d5e-73d49e99a8f8/cache.tzst -P -C /home/runner/work/links-notation/links-notation --use-compress-program unzstd
+2026-08-28T11:49:53.8820213Z Cache restored successfully
+2026-08-28T11:49:53.8827348Z Restored from cache key "v0-rust-publishToCratesIO-Linux-x64-0b9fd15e-2cd05178" full match: true.
+2026-08-28T11:49:53.8930373Z ##[group]Run cargo build --release
+2026-08-28T11:49:53.8930718Z [36;1mcargo build --release[0m
+2026-08-28T11:49:53.8967238Z shell: /usr/bin/bash -e {0}
+2026-08-28T11:49:53.8967518Z env:
+2026-08-28T11:49:53.8970664Z GITHUB_TOKEN: ***
+2026-08-28T11:49:53.8970917Z CI_VERBOSE: false
+2026-08-28T11:49:53.8971165Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:49:53.8971425Z CARGO_INCREMENTAL: 0
+2026-08-28T11:49:53.8971648Z CARGO_TERM_COLOR: always
+2026-08-28T11:49:53.8971890Z CACHE_ON_FAILURE: false
+2026-08-28T11:49:53.8972115Z ##[endgroup]
+2026-08-28T11:49:54.3096433Z [1m[92m Updating[0m crates.io index
+2026-08-28T11:49:54.5237956Z [1m[92m Locking[0m 6 packages to latest compatible versions
+2026-08-28T11:49:54.6702072Z [1m[92m Compiling[0m links-notation-macro v0.1.0 (/home/runner/work/links-notation/links-notation/rust/links-notation-macro)
+2026-08-28T11:50:00.8996160Z [1m[92m Compiling[0m links-notation v0.16.0 (/home/runner/work/links-notation/links-notation/rust/links-notation)
+2026-08-28T11:50:02.6976138Z [1m[92m Finished[0m `release` profile [optimized] target(s) in 8.78s
+2026-08-28T11:50:02.7168703Z ##[group]Run rust-lang/crates-io-auth-action@v1
+2026-08-28T11:50:02.7169348Z with:
+2026-08-28T11:50:02.7169570Z url: https://crates.io
+2026-08-28T11:50:02.7169830Z env:
+2026-08-28T11:50:02.7172661Z GITHUB_TOKEN: ***
+2026-08-28T11:50:02.7172892Z CI_VERBOSE: false
+2026-08-28T11:50:02.7173124Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:50:02.7173387Z CARGO_INCREMENTAL: 0
+2026-08-28T11:50:02.7173615Z CARGO_TERM_COLOR: always
+2026-08-28T11:50:02.7173855Z CACHE_ON_FAILURE: false
+2026-08-28T11:50:02.7174081Z ##[endgroup]
+2026-08-28T11:50:02.7941333Z Retrieving GitHub Actions JWT token with audience: crates.io
+2026-08-28T11:50:03.0376617Z Retrieved JWT token successfully
+2026-08-28T11:50:03.0377719Z Requesting token from: https://crates.io/api/v1/trusted_publishing/tokens. User agent: crates-io-auth-action/1.0.1
+2026-08-28T11:50:03.1683205Z ##[error]Failed to retrieve token from Cargo registry. Status: 400. Error: No Trusted Publishing config found for repository `link-foundation/links-notation`.
+2026-08-28T11:50:03.2117635Z ##[group]Run set -euo pipefail
+2026-08-28T11:50:03.2118041Z [36;1mset -euo pipefail[0m
+2026-08-28T11:50:03.2118296Z [36;1m[0m
+2026-08-28T11:50:03.2118628Z [36;1mread_field() { grep "^$2 = " "$1" | head -1 | sed "s/$2 = \"\(.*\)\"/\1/"; }[0m
+2026-08-28T11:50:03.2119510Z [36;1mMACRO_VERSION=$(read_field links-notation-macro/Cargo.toml version)[0m
+2026-08-28T11:50:03.2120016Z [36;1mPACKAGE_NAME=$(read_field links-notation/Cargo.toml name)[0m
+2026-08-28T11:50:03.2120474Z [36;1mPACKAGE_VERSION=$(read_field links-notation/Cargo.toml version)[0m
+2026-08-28T11:50:03.2120904Z [36;1mecho "name=$PACKAGE_NAME" >> "$GITHUB_OUTPUT"[0m
+2026-08-28T11:50:03.2121283Z [36;1mecho "version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"[0m
+2026-08-28T11:50:03.2121611Z [36;1m[0m
+2026-08-28T11:50:03.2121929Z [36;1m# The token never appears on the command line, where it would be[0m
+2026-08-28T11:50:03.2122387Z [36;1m# visible to every other process on the runner via /proc.[0m
+2026-08-28T11:50:03.2122858Z [36;1mexport CARGO_REGISTRY_TOKEN="${OIDC_TOKEN:-$CARGO_TOKEN}"[0m
+2026-08-28T11:50:03.2123230Z [36;1mif [ -n "${OIDC_TOKEN:-}" ]; then[0m
+2026-08-28T11:50:03.2123781Z [36;1m echo "crates.io credential: trusted publishing (OIDC)"[0m
+2026-08-28T11:50:03.2124149Z [36;1melif [ -n "${CARGO_TOKEN:-}" ]; then[0m
+2026-08-28T11:50:03.2124508Z [36;1m echo "crates.io credential: CARGO_TOKEN present"[0m
+2026-08-28T11:50:03.2124840Z [36;1melse[0m
+2026-08-28T11:50:03.2125091Z [36;1m echo "published=failed" >> "$GITHUB_OUTPUT"[0m
+2026-08-28T11:50:03.2125765Z [36;1m echo "::warning::No crates.io credential (neither trusted publishing nor CARGO_TOKEN); skipping publish. No GitHub release will be created."[0m
+2026-08-28T11:50:03.2126384Z [36;1m exit 0[0m
+2026-08-28T11:50:03.2126587Z [36;1mfi[0m
+2026-08-28T11:50:03.2126782Z [36;1m[0m
+2026-08-28T11:50:03.2126980Z [36;1mpublish_crate() {[0m
+2026-08-28T11:50:03.2127248Z [36;1m local crate="$1" version="$2" log="$3"[0m
+2026-08-28T11:50:03.2127619Z [36;1m if cargo publish -p "$crate" 2>&1 | tee "$log"; then[0m
+2026-08-28T11:50:03.2127976Z [36;1m echo "Published ${crate}@${version}"[0m
+2026-08-28T11:50:03.2128274Z [36;1m return 0[0m
+2026-08-28T11:50:03.2128485Z [36;1m fi[0m
+2026-08-28T11:50:03.2128768Z [36;1m if grep -q "already exists on crates.io index" "$log"; then[0m
+2026-08-28T11:50:03.2129482Z [36;1m echo "${crate}@${version} is already on crates.io"[0m
+2026-08-28T11:50:03.2129817Z [36;1m return 2[0m
+2026-08-28T11:50:03.2130029Z [36;1m fi[0m
+2026-08-28T11:50:03.2130316Z [36;1m echo "::error::Failed to publish ${crate}@${version}"[0m
+2026-08-28T11:50:03.2130651Z [36;1m return 1[0m
+2026-08-28T11:50:03.2130863Z [36;1m}[0m
+2026-08-28T11:50:03.2131050Z [36;1m[0m
+2026-08-28T11:50:03.2131357Z [36;1m# The main crate depends on links-notation-macro, so that one has to[0m
+2026-08-28T11:50:03.2131793Z [36;1m# land, and be visible in the index, first.[0m
+2026-08-28T11:50:03.2132089Z [36;1mset +e[0m
+2026-08-28T11:50:03.2132435Z [36;1mpublish_crate links-notation-macro "$MACRO_VERSION" macro_publish.log[0m
+2026-08-28T11:50:03.2132848Z [36;1mMACRO_STATUS=$?[0m
+2026-08-28T11:50:03.2133085Z [36;1mset -e[0m
+2026-08-28T11:50:03.2133333Z [36;1mif [ $MACRO_STATUS -eq 1 ]; then exit 1; fi[0m
+2026-08-28T11:50:03.2133623Z [36;1m[0m
+2026-08-28T11:50:03.2133825Z [36;1mif [ $MACRO_STATUS -eq 0 ]; then[0m
+2026-08-28T11:50:03.2134116Z [36;1m for attempt in $(seq 1 20); do[0m
+2026-08-28T11:50:03.2134666Z [36;1m if curl -fsS "https://crates.io/api/v1/crates/links-notation-macro/${MACRO_VERSION}" >/dev/null 2>&1; then[0m
+2026-08-28T11:50:03.2135350Z [36;1m echo "links-notation-macro@${MACRO_VERSION} is visible in the index"[0m
+2026-08-28T11:50:03.2135750Z [36;1m break[0m
+2026-08-28T11:50:03.2135964Z [36;1m fi[0m
+2026-08-28T11:50:03.2136289Z [36;1m echo "Waiting for the index to catch up (attempt ${attempt}/20)"[0m
+2026-08-28T11:50:03.2136664Z [36;1m sleep 15[0m
+2026-08-28T11:50:03.2137051Z [36;1m done[0m
+2026-08-28T11:50:03.2137266Z [36;1mfi[0m
+2026-08-28T11:50:03.2137458Z [36;1m[0m
+2026-08-28T11:50:03.2137644Z [36;1mset +e[0m
+2026-08-28T11:50:03.2137971Z [36;1mpublish_crate "$PACKAGE_NAME" "$PACKAGE_VERSION" publish.log[0m
+2026-08-28T11:50:03.2138349Z [36;1mSTATUS=$?[0m
+2026-08-28T11:50:03.2138566Z [36;1mset -e[0m
+2026-08-28T11:50:03.2138770Z [36;1mcase $STATUS in[0m
+2026-08-28T11:50:03.2139317Z [36;1m 0) echo "published=true" >> "$GITHUB_OUTPUT" ;;[0m
+2026-08-28T11:50:03.2139722Z [36;1m 2) echo "published=skipped" >> "$GITHUB_OUTPUT" ;;[0m
+2026-08-28T11:50:03.2140123Z [36;1m *) echo "published=failed" >> "$GITHUB_OUTPUT"; exit 1 ;;[0m
+2026-08-28T11:50:03.2140458Z [36;1mesac[0m
+2026-08-28T11:50:03.2176144Z shell: /usr/bin/bash -e {0}
+2026-08-28T11:50:03.2176419Z env:
+2026-08-28T11:50:03.2179482Z GITHUB_TOKEN: ***
+2026-08-28T11:50:03.2179730Z CI_VERBOSE: false
+2026-08-28T11:50:03.2179969Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:50:03.2180244Z CARGO_INCREMENTAL: 0
+2026-08-28T11:50:03.2180483Z CARGO_TERM_COLOR: always
+2026-08-28T11:50:03.2180727Z CACHE_ON_FAILURE: false
+2026-08-28T11:50:03.2180952Z OIDC_TOKEN:
+2026-08-28T11:50:03.2181386Z CARGO_TOKEN: ***
+2026-08-28T11:50:03.2181599Z ##[endgroup]
+2026-08-28T11:50:03.2308506Z crates.io credential: CARGO_TOKEN present
+2026-08-28T11:50:03.2591713Z [1m[92m Updating[0m crates.io index
+2026-08-28T11:50:03.3076685Z [1m[91merror[0m: crate links-notation-macro@0.1.0 already exists on crates.io index
+2026-08-28T11:50:03.3109455Z links-notation-macro@0.1.0 is already on crates.io
+2026-08-28T11:50:03.3283292Z [1m[92m Updating[0m crates.io index
+2026-08-28T11:50:03.6020062Z [1m[92m Packaging[0m links-notation v0.16.0 (/home/runner/work/links-notation/links-notation/rust/links-notation)
+2026-08-28T11:50:03.6033457Z [1m[92m Updating[0m crates.io index
+2026-08-28T11:50:03.6216013Z [1m[92m Packaged[0m 30 files, 254.0KiB (41.2KiB compressed)
+2026-08-28T11:50:03.6217132Z [1m[92m Verifying[0m links-notation v0.16.0 (/home/runner/work/links-notation/links-notation/rust/links-notation)
+2026-08-28T11:50:03.6267028Z [1m[92m Downloading[0m crates ...
+2026-08-28T11:50:03.6809320Z [1m[92m Downloaded[0m links-notation-macro v0.1.0
+2026-08-28T11:50:03.6864543Z [1m[92m Compiling[0m proc-macro2 v1.0.107
+2026-08-28T11:50:03.6872264Z [1m[92m Compiling[0m unicode-ident v1.0.24
+2026-08-28T11:50:03.6873863Z [1m[92m Compiling[0m quote v1.0.47
+2026-08-28T11:50:03.6875011Z [1m[92m Compiling[0m memchr v2.8.3
+2026-08-28T11:50:04.1551883Z [1m[92m Compiling[0m nom v8.0.0
+2026-08-28T11:50:05.0041650Z [1m[92m Compiling[0m syn v3.0.4
+2026-08-28T11:50:07.2394554Z [1m[92m Compiling[0m links-notation-macro v0.1.0
+2026-08-28T11:50:07.4595748Z [1m[92m Compiling[0m links-notation v0.16.0 (/home/runner/work/links-notation/links-notation/rust/target/package/links-notation-0.16.0)
+2026-08-28T11:50:07.8948025Z [1m[92m Finished[0m `dev` profile [unoptimized + debuginfo] target(s) in 4.57s
+2026-08-28T11:50:07.8960054Z [1m[92m Uploading[0m links-notation v0.16.0 (/home/runner/work/links-notation/links-notation/rust/links-notation)
+2026-08-28T11:50:08.6497339Z [1m[92m Uploaded[0m links-notation v0.16.0 to registry `crates-io`
+2026-08-28T11:50:08.6500222Z [1m[92mnote[0m: waiting for links-notation v0.16.0 to be available at registry `crates-io`
+2026-08-28T11:50:08.6501365Z [1m[96mhelp[0m: you may press ctrl-c to skip waiting; the crate should be available shortly
+2026-08-28T11:50:09.6925722Z [1m[92m Published[0m links-notation v0.16.0 at registry `crates-io`
+2026-08-28T11:50:09.6972581Z Published links-notation@0.16.0
+2026-08-28T11:50:09.7026217Z ##[group]Run set -euo pipefail
+2026-08-28T11:50:09.7026574Z [36;1mset -euo pipefail[0m
+2026-08-28T11:50:09.7026905Z [36;1mfor attempt in $(seq 1 20); do[0m
+2026-08-28T11:50:09.7027437Z [36;1m if curl -fsS "https://crates.io/api/v1/crates/${PACKAGE_NAME}/${PACKAGE_VERSION}" >/dev/null 2>&1; then[0m
+2026-08-28T11:50:09.7028145Z [36;1m echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on crates.io (attempt ${attempt})"[0m
+2026-08-28T11:50:09.7028593Z [36;1m exit 0[0m
+2026-08-28T11:50:09.7028824Z [36;1m fi[0m
+2026-08-28T11:50:09.7029469Z [36;1m echo "Not visible yet, retrying in 15s (attempt ${attempt}/20)"[0m
+2026-08-28T11:50:09.7029846Z [36;1m sleep 15[0m
+2026-08-28T11:50:09.7030065Z [36;1mdone[0m
+2026-08-28T11:50:09.7030467Z [36;1mecho "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on crates.io within 5 minutes"[0m
+2026-08-28T11:50:09.7030924Z [36;1mexit 1[0m
+2026-08-28T11:50:09.7067054Z shell: /usr/bin/bash -e {0}
+2026-08-28T11:50:09.7067348Z env:
+2026-08-28T11:50:09.7070657Z GITHUB_TOKEN: ***
+2026-08-28T11:50:09.7070904Z CI_VERBOSE: false
+2026-08-28T11:50:09.7071131Z CARGO_HOME: /home/runner/.cargo
+2026-08-28T11:50:09.7071397Z CARGO_INCREMENTAL: 0
+2026-08-28T11:50:09.7071622Z CARGO_TERM_COLOR: always
+2026-08-28T11:50:09.7071858Z CACHE_ON_FAILURE: false
+2026-08-28T11:50:09.7072107Z PACKAGE_NAME: links-notation
+2026-08-28T11:50:09.7072359Z PACKAGE_VERSION: 0.16.0
+2026-08-28T11:50:09.7072589Z ##[endgroup]
+2026-08-28T11:50:09.7617806Z Not visible yet, retrying in 15s (attempt 1/20)
+2026-08-28T11:50:24.8094849Z Not visible yet, retrying in 15s (attempt 2/20)
+2026-08-28T11:50:39.8552015Z Not visible yet, retrying in 15s (attempt 3/20)
+2026-08-28T11:50:54.9000517Z Not visible yet, retrying in 15s (attempt 4/20)
+2026-08-28T11:51:09.9637594Z Not visible yet, retrying in 15s (attempt 5/20)
+2026-08-28T11:51:25.0148472Z Not visible yet, retrying in 15s (attempt 6/20)
+2026-08-28T11:51:40.0593280Z Not visible yet, retrying in 15s (attempt 7/20)
+2026-08-28T11:51:55.1045271Z Not visible yet, retrying in 15s (attempt 8/20)
+2026-08-28T11:52:10.1636246Z Not visible yet, retrying in 15s (attempt 9/20)
+2026-08-28T11:52:25.2064152Z Not visible yet, retrying in 15s (attempt 10/20)
+2026-08-28T11:52:40.4184660Z Not visible yet, retrying in 15s (attempt 11/20)
+2026-08-28T11:52:55.4649509Z Not visible yet, retrying in 15s (attempt 12/20)
+2026-08-28T11:53:10.5174718Z Not visible yet, retrying in 15s (attempt 13/20)
+2026-08-28T11:53:25.5641034Z Not visible yet, retrying in 15s (attempt 14/20)
+2026-08-28T11:53:40.6338419Z Not visible yet, retrying in 15s (attempt 15/20)
+2026-08-28T11:53:55.6793895Z Not visible yet, retrying in 15s (attempt 16/20)
+2026-08-28T11:54:10.7474938Z Not visible yet, retrying in 15s (attempt 17/20)
+2026-08-28T11:54:25.7940489Z Not visible yet, retrying in 15s (attempt 18/20)
+2026-08-28T11:54:40.8498593Z Not visible yet, retrying in 15s (attempt 19/20)
+2026-08-28T11:54:55.8946488Z Not visible yet, retrying in 15s (attempt 20/20)
+2026-08-28T11:55:10.8969972Z ##[error]links-notation@0.16.0 did not appear on crates.io within 5 minutes
+2026-08-28T11:55:10.8973351Z ##[error]Process completed with exit code 1.
+2026-08-28T11:55:10.9035792Z Post job cleanup.
+2026-08-28T11:55:10.9811693Z No token to revoke
+2026-08-28T11:55:10.9972723Z Post job cleanup.
+2026-08-28T11:55:11.0856094Z [command]/usr/bin/git version
+2026-08-28T11:55:11.0915233Z git version 2.55.0
+2026-08-28T11:55:11.0960100Z Temporarily overriding HOME='/home/runner/work/_temp/fb22e297-d34b-41e7-9ebf-29ba2028a7bd' before making global git config changes
+2026-08-28T11:55:11.0961504Z Adding repository directory to the temporary git global config as a safe directory
+2026-08-28T11:55:11.0967723Z [command]/usr/bin/git config --global --add safe.directory /home/runner/work/links-notation/links-notation
+2026-08-28T11:55:11.1000874Z Removing SSH command configuration
+2026-08-28T11:55:11.1007895Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
+2026-08-28T11:55:11.1045221Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
+2026-08-28T11:55:11.1301024Z Removing HTTP extra header
+2026-08-28T11:55:11.1307163Z [command]/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader
+2026-08-28T11:55:11.1349263Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :"
+2026-08-28T11:55:11.1607181Z Removing includeIf entries pointing to credentials config files
+2026-08-28T11:55:11.1616284Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
+2026-08-28T11:55:11.1656431Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
+2026-08-28T11:55:11.2148275Z Evaluate and set job outputs
+2026-08-28T11:55:11.2155621Z Set output 'published'
+2026-08-28T11:55:11.2158773Z Set output 'version'
+2026-08-28T11:55:11.2160082Z Set output 'name'
+2026-08-28T11:55:11.2161188Z Cleaning up orphan processes
diff --git a/dev/log/issues/298/pulls/299/templates/csharp-workflows.txt b/dev/log/issues/298/pulls/299/templates/csharp-workflows.txt
new file mode 100644
index 00000000..30ba50af
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/csharp-workflows.txt
@@ -0,0 +1,5 @@
+docs.yml
+links.yml
+release.yml
+security.yml
+workflows.yml
diff --git a/dev/log/issues/298/pulls/299/templates/csharp/docs.yml b/dev/log/issues/298/pulls/299/templates/csharp/docs.yml
new file mode 100644
index 00000000..642b36aa
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/csharp/docs.yml
@@ -0,0 +1,110 @@
+name: docs
+
+# Build and deploy DocFX API documentation to GitHub Pages.
+#
+# Build runs on every push to main and on PRs that touch docs, sources, or
+# this workflow. Publishing is gated on `push` to `main` (and manual dispatch)
+# plus an explicit DEPLOY_GITHUB_PAGES=true repository variable, so fresh
+# repositories keep docs build validation without failing before Pages is
+# enabled and configured. See issue #15 for the failure mode that gating on
+# releases produces.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'docs/**'
+ - 'src/**'
+ - 'docfx.json'
+ - '.github/workflows/docs.yml'
+ pull_request:
+ branches: [main]
+ paths:
+ - 'docs/**'
+ - 'src/**'
+ - 'docfx.json'
+ - '.github/workflows/docs.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+ DOTNET_NOLOGO: true
+
+jobs:
+ build:
+ name: Build documentation
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Install DocFX
+ run: dotnet tool update -g docfx
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build documentation site
+ run: docfx docfx.json -o _site
+
+ - name: List built site (debug)
+ run: |
+ echo "::group::_site tree"
+ find _site -maxdepth 3 -print
+ echo "::endgroup::"
+
+ - name: Skip GitHub Pages deployment
+ if: |
+ ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') &&
+ vars.DEPLOY_GITHUB_PAGES != 'true'
+ run: |
+ echo "::notice::GitHub Pages deployment is disabled. Configure Pages, set repository variable DEPLOY_GITHUB_PAGES=true, then rerun this workflow to publish docs."
+
+ - name: Configure GitHub Pages
+ if: |
+ ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') &&
+ vars.DEPLOY_GITHUB_PAGES == 'true'
+ uses: actions/configure-pages@v6
+
+ - name: Upload GitHub Pages artifact
+ if: |
+ ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') &&
+ vars.DEPLOY_GITHUB_PAGES == 'true'
+ uses: actions/upload-pages-artifact@v5
+ with:
+ path: _site
+
+ deploy:
+ name: Deploy to GitHub Pages
+ if: |
+ ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') &&
+ vars.DEPLOY_GITHUB_PAGES == 'true'
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v5
+
+ - name: Print resolved deployment URL (debug)
+ run: |
+ echo "Pages deployed to: ${{ steps.deployment.outputs.page_url }}"
diff --git a/dev/log/issues/298/pulls/299/templates/csharp/links.yml b/dev/log/issues/298/pulls/299/templates/csharp/links.yml
new file mode 100644
index 00000000..e685934e
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/csharp/links.yml
@@ -0,0 +1,101 @@
+name: Broken Link Checker
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - '**.md'
+ - '**.html'
+ - '.github/workflows/links.yml'
+ pull_request:
+ types: [opened, synchronize, reopened]
+ paths:
+ - '**.md'
+ - '**.html'
+ - '.github/workflows/links.yml'
+ workflow_dispatch:
+
+# Least-privilege default; jobs escalate individually when needed.
+permissions:
+ contents: read
+
+# Provide Git config to actions/checkout itself; checkout runs git init before
+# any workflow step can configure Git.
+env:
+ GIT_CONFIG_COUNT: '1'
+ GIT_CONFIG_KEY_0: init.defaultBranch
+ GIT_CONFIG_VALUE_0: main
+
+jobs:
+ link-checker:
+ name: Check Links
+ runs-on: ubuntu-latest
+ # Typical run: <1min with lychee cache. 10min prevents slow
+ # external hosts or Wayback Machine probes from hanging the workflow.
+ timeout-minutes: 10
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-link-checker
+ cancel-in-progress: true
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Check links with lychee
+ id: lychee
+ uses: lycheeverse/lychee-action@v2
+ with:
+ # Check all Markdown and HTML files
+ # Exclude case-studies directory - these are research documents from
+ # external repos with references to files and issues that don't exist
+ # in this repository (similar exclusion pattern as eslint.config.js)
+ # Exclude scripts/fixtures - captured lychee reports whose links are
+ # deliberately broken so the parser tests have something to parse
+ args: >-
+ --verbose
+ --no-progress
+ --cache
+ --max-cache-age 1d
+ --max-retries 3
+ --timeout 30
+ --exclude-path docs/case-studies
+ --exclude-path scripts/fixtures
+ './**/*.md'
+ './**/*.html'
+ # Don't fail the workflow immediately - we want to check web archive first
+ fail: false
+ # Output file for broken links report (used by check-web-archive.mjs)
+ output: lychee/out.md
+ # Write a job summary
+ jobSummary: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Check broken links against Web Archive
+ if: steps.lychee.outputs.exit_code != 0
+ id: webarchive
+ run: node scripts/check-web-archive.mjs
+ env:
+ LYCHEE_OUTPUT: lychee/out.md
+
+ - name: Fail if broken links found and no web archive fallback
+ if: steps.lychee.outputs.exit_code != 0 && steps.webarchive.outputs.all_archived != 'true'
+ run: |
+ echo "::error::Broken links were detected with no Web Archive fallback available."
+ echo ""
+ echo "What happened:"
+ echo " lychee found one or more broken links in the *.md and *.html files of this repository."
+ echo " The Web Archive (Wayback Machine) check found no archived versions for some of them."
+ echo ""
+ echo "How to fix:"
+ echo " 1. Review the 'Check links with lychee' step above for a full list of broken links."
+ echo " 2. For links marked with a '::notice::' annotation above, a Web Archive version exists."
+ echo " Replace those broken links with the suggested archive.org URL."
+ echo " 3. For links with no archive version, either:"
+ echo " a. Find an updated URL that points to the same or equivalent content."
+ echo " b. Remove the link if the content is no longer relevant."
+ echo " c. Add the URL to .lycheeignore if it is a known false positive."
+ echo ""
+ echo "Report location: lychee/out.md (available as a workflow artifact if configured)."
+ exit 1
diff --git a/dev/log/issues/298/pulls/299/templates/csharp/release.yml b/dev/log/issues/298/pulls/299/templates/csharp/release.yml
new file mode 100644
index 00000000..7703fc68
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/csharp/release.yml
@@ -0,0 +1,796 @@
+name: CI/CD Pipeline
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ types: [opened, synchronize, reopened]
+ workflow_dispatch:
+ inputs:
+ release_mode:
+ description: 'Release mode'
+ required: true
+ type: choice
+ default: 'instant'
+ options:
+ - instant
+ - changeset-pr
+ bump_type:
+ description: 'Version bump type'
+ required: true
+ type: choice
+ options:
+ - patch
+ - minor
+ - major
+ description:
+ description: 'Release description (optional)'
+ required: false
+ type: string
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+env:
+ GIT_CONFIG_COUNT: '1'
+ GIT_CONFIG_KEY_0: init.defaultBranch
+ GIT_CONFIG_VALUE_0: main
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+ DOTNET_NOLOGO: true
+
+jobs:
+ # === DETECT CHANGES - determines which jobs should run ===
+ detect-changes:
+ name: Detect Changes
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ if: github.event_name != 'workflow_dispatch'
+ outputs:
+ any-code-changed: ${{ steps.changes.outputs.any-code-changed }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Detect C# layout
+ id: csharp_layout
+ shell: bash
+ run: |
+ set -euo pipefail
+ if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="."
+ MULTI_LANGUAGE="false"
+ elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="csharp"
+ MULTI_LANGUAGE="true"
+ else
+ echo "::error::Could not find a C# project at the repository root or under csharp/"
+ exit 1
+ fi
+ echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT"
+ echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT"
+ echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE"
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Detect changes
+ id: changes
+ env:
+ GITHUB_EVENT_NAME: ${{ github.event_name }}
+ GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ CSHARP_ROOT: ${{ steps.csharp_layout.outputs.root }}
+ run: bun run "${{ steps.csharp_layout.outputs.root }}/scripts/detect-code-changes.mjs"
+
+ # === CHANGESET CHECK - only runs on PRs with code changes ===
+ # Docs-only PRs (./docs folder, markdown files) don't require changesets
+ changeset-check:
+ name: Changeset Validation
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ needs: [detect-changes]
+ if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Detect C# layout
+ id: csharp_layout
+ shell: bash
+ run: |
+ set -euo pipefail
+ if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="."
+ MULTI_LANGUAGE="false"
+ elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="csharp"
+ MULTI_LANGUAGE="true"
+ else
+ echo "::error::Could not find a C# project at the repository root or under csharp/"
+ exit 1
+ fi
+ echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT"
+ echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT"
+ echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE"
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Validate changeset
+ env:
+ GITHUB_BASE_REF: ${{ github.base_ref }}
+ GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ CSHARP_ROOT: ${{ steps.csharp_layout.outputs.root }}
+ GITHUB_HEAD_REF: ${{ github.head_ref }}
+ run: |
+ # Skip changeset check for automated release PRs
+ # Read head_ref through env, never interpolate it into the script:
+ # on a fork PR the branch name is attacker-controlled.
+ if [[ "$GITHUB_HEAD_REF" == "changeset-release/"* ]] || [[ "$GITHUB_HEAD_REF" == "changeset-manual-release-"* ]]; then
+ echo "Skipping changeset check for automated release PR"
+ exit 0
+ fi
+
+ # Run changeset validation script
+ bun run "$CSHARP_ROOT/scripts/validate-changeset.mjs"
+
+ # === LINT AND FORMAT CHECK ===
+ # Lint runs independently of changeset-check - it's a fast check that should always run
+ # See: https://github.com/link-foundation/js-ai-driven-development-pipeline-template/pull/18 for why this dependency was removed
+ lint:
+ name: Lint and Format Check
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ needs: [detect-changes]
+ # detect-changes is intentionally skipped for workflow_dispatch. Without a
+ # status-check function, GitHub adds an implicit success() on detect-changes
+ # and skips lint (and everything that needs it, including instant-release).
+ # always() && !cancelled() lets the OR conditions decide instead.
+ if: |
+ always() && !cancelled() && (
+ github.event_name == 'workflow_dispatch' ||
+ needs.detect-changes.outputs.any-code-changed == 'true'
+ )
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Detect C# layout
+ id: csharp_layout
+ shell: bash
+ run: |
+ set -euo pipefail
+ if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="."
+ MULTI_LANGUAGE="false"
+ elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="csharp"
+ MULTI_LANGUAGE="true"
+ else
+ echo "::error::Could not find a C# project at the repository root or under csharp/"
+ exit 1
+ fi
+ echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT"
+ echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT"
+ echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE"
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Restore dependencies
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: dotnet restore
+
+ - name: Check formatting
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: dotnet format --verify-no-changes --verbosity diagnostic
+
+ - name: Build with warnings as errors
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: dotnet build --no-restore --configuration Release /warnaserror
+
+ - name: Run script tests
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: bun test scripts/*.test.mjs
+
+ - name: Check file size limit
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: bun run scripts/check-file-size.mjs
+
+ # === TEST ON MULTIPLE OS ===
+ test:
+ name: Test (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+ needs: [detect-changes]
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+ # Run tests only for events or file changes that affect runtime behavior.
+ if: |
+ always() && !cancelled() && (
+ github.event_name == 'workflow_dispatch' ||
+ needs.detect-changes.outputs.any-code-changed == 'true'
+ )
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Detect C# layout
+ id: csharp_layout
+ shell: bash
+ run: |
+ set -euo pipefail
+ if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="."
+ MULTI_LANGUAGE="false"
+ elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="csharp"
+ MULTI_LANGUAGE="true"
+ else
+ echo "::error::Could not find a C# project at the repository root or under csharp/"
+ exit 1
+ fi
+ echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT"
+ echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT"
+ echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE"
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Restore dependencies
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: dotnet restore
+
+ - name: Build
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Run tests
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage"
+
+ - name: Skip Codecov upload when token is unavailable
+ if: matrix.os == 'ubuntu-latest' && env.CODECOV_TOKEN == ''
+ run: echo "::notice::CODECOV_TOKEN is not configured; skipping Codecov upload."
+
+ - name: Upload coverage to Codecov
+ if: matrix.os == 'ubuntu-latest' && env.CODECOV_TOKEN != ''
+ uses: codecov/codecov-action@v7
+ with:
+ token: ${{ env.CODECOV_TOKEN }}
+ fail_ci_if_error: true
+
+ # === BUILD PACKAGE ===
+ # Only runs if lint and test pass
+ build:
+ name: Build Package
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ needs: [lint, test]
+ if: always() && needs.lint.result == 'success' && needs.test.result == 'success'
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Detect C# layout
+ id: csharp_layout
+ shell: bash
+ run: |
+ set -euo pipefail
+ if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="."
+ MULTI_LANGUAGE="false"
+ elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="csharp"
+ MULTI_LANGUAGE="true"
+ else
+ echo "::error::Could not find a C# project at the repository root or under csharp/"
+ exit 1
+ fi
+ echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT"
+ echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT"
+ echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE"
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Restore dependencies
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: dotnet restore
+
+ - name: Build Release
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Pack NuGet package
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: dotnet pack --no-build --configuration Release --output ./artifacts
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v7
+ with:
+ name: nuget-package
+ path: ${{ steps.csharp_layout.outputs.root }}/artifacts/*.nupkg
+
+ # === AUTOMATIC RELEASE ===
+ # Runs on push to main using changesets
+ release:
+ name: Release
+ needs: [lint, test, build]
+ if: always() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: write
+ packages: write
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Detect C# layout
+ id: csharp_layout
+ shell: bash
+ run: |
+ set -euo pipefail
+ if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="."
+ MULTI_LANGUAGE="false"
+ elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="csharp"
+ MULTI_LANGUAGE="true"
+ else
+ echo "::error::Could not find a C# project at the repository root or under csharp/"
+ exit 1
+ fi
+ echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT"
+ echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT"
+ echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE"
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Check for changesets
+ id: check_changesets
+ run: |
+ # Count changeset files (excluding README.md and config.json)
+ CHANGESET_DIR="${{ steps.csharp_layout.outputs.root }}/.changeset"
+ CHANGESET_COUNT=$(find "$CHANGESET_DIR" -name "*.md" ! -name "README.md" 2>/dev/null | wc -l)
+ echo "Found $CHANGESET_COUNT changeset file(s)"
+ echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> "$GITHUB_OUTPUT"
+ echo "changeset_count=$CHANGESET_COUNT" >> "$GITHUB_OUTPUT"
+
+ - name: Check if release is needed
+ # Self-healing gate: even when a changeset is absent, resume publishing
+ # if the csproj is missing on NuGet or its GitHub release does
+ # not exist. See issue #11 and the JS template's check-release-needed.mjs
+ # for the same pattern.
+ id: check_release
+ env:
+ HAS_CHANGESETS: ${{ steps.check_changesets.outputs.has_changesets }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ CSHARP_ROOT: ${{ steps.csharp_layout.outputs.root }}
+ run: |
+ bun run "${{ steps.csharp_layout.outputs.root }}/scripts/check-release-needed.mjs" \
+ --csharp-root "${{ steps.csharp_layout.outputs.root }}"
+
+ - name: Merge multiple changesets
+ if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: |
+ echo "Multiple changesets detected, merging..."
+ bun run scripts/merge-changesets.mjs
+
+ - name: Version and commit
+ if: steps.check_changesets.outputs.has_changesets == 'true'
+ id: version
+ run: |
+ bun run "${{ steps.csharp_layout.outputs.root }}/scripts/version-and-commit.mjs" \
+ --mode changeset \
+ --csharp-root "${{ steps.csharp_layout.outputs.root }}"
+
+ - name: Resolve release version
+ # Picks the version that downstream steps should publish. Prefers the
+ # one just committed; falls back to the csproj reported by
+ # check-release-needed for self-healing re-runs.
+ id: release_version
+ if: >-
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true' ||
+ (steps.check_release.outputs.should_release == 'true' &&
+ steps.check_release.outputs.skip_bump == 'true')
+ run: |
+ if [ -n "${{ steps.version.outputs.new_version }}" ]; then
+ VERSION="${{ steps.version.outputs.new_version }}"
+ else
+ VERSION="${{ steps.check_release.outputs.current_version }}"
+ fi
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "Releasing version: $VERSION"
+
+ - name: Build release package
+ if: >-
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true' ||
+ (steps.check_release.outputs.should_release == 'true' &&
+ steps.check_release.outputs.skip_bump == 'true')
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: |
+ dotnet restore
+ dotnet build --configuration Release
+ dotnet pack --no-build --configuration Release --output ./artifacts
+
+ - name: Resolve NuGet package id
+ id: package
+ if: >-
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true' ||
+ (steps.check_release.outputs.should_release == 'true' &&
+ steps.check_release.outputs.skip_bump == 'true')
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: |
+ PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:PackageId | tail -n 1 | tr -d '\r')
+ if [ -z "$PACKAGE_ID" ]; then
+ PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:AssemblyName | tail -n 1 | tr -d '\r')
+ fi
+ if [ -z "$PACKAGE_ID" ]; then
+ PACKAGE_ID="MyPackage"
+ fi
+ echo "id=$PACKAGE_ID" >> "$GITHUB_OUTPUT"
+
+ - name: Validate NuGet API key
+ # Upfront validation surfaces an expired/invalid NUGET_API_KEY before
+ # we attempt a push that would otherwise return HTTP 403 mid-flight.
+ if: >-
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true' ||
+ (steps.check_release.outputs.should_release == 'true' &&
+ steps.check_release.outputs.skip_bump == 'true')
+ env:
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ run: |
+ if [ -z "$NUGET_API_KEY" ]; then
+ echo "::warning::NUGET_API_KEY is not configured — NuGet publish will be skipped."
+ exit 0
+ fi
+ echo "NUGET_API_KEY length: ${#NUGET_API_KEY}"
+
+ - name: Publish to NuGet
+ id: nuget_publish
+ if: >-
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true' ||
+ (steps.check_release.outputs.should_release == 'true' &&
+ steps.check_release.outputs.skip_bump == 'true')
+ env:
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: |
+ if [ -n "$NUGET_API_KEY" ]; then
+ dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
+ echo "published=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "NUGET_API_KEY not set, skipping NuGet publish"
+ echo "published=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Wait for NuGet indexing
+ # NuGet's flat-container API can take up to 15 minutes to reflect a
+ # newly pushed package (see issue #13). Poll it via the tested helper
+ # before creating the GitHub release so users can `dotnet add package`
+ # the version mentioned in the release notes.
+ if: >-
+ steps.nuget_publish.outputs.published == 'true' && (
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true' ||
+ (steps.check_release.outputs.should_release == 'true' &&
+ steps.check_release.outputs.skip_bump == 'true'))
+ run: |
+ bun run "${{ steps.csharp_layout.outputs.root }}/scripts/wait-for-nuget.mjs" \
+ --package-id "${{ steps.package.outputs.id }}" \
+ --release-version "${{ steps.release_version.outputs.version }}"
+
+ - name: Smoke-test published NuGet package
+ # Indexing proves the version exists; this installs it in a clean
+ # throwaway project and runs the advertised library entry point before
+ # release notes are published. The helper captures command output
+ # before previewing it, avoiding SIGPIPE-prone live-output pagination.
+ if: >-
+ steps.nuget_publish.outputs.published == 'true' && (
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true' ||
+ (steps.check_release.outputs.should_release == 'true' &&
+ steps.check_release.outputs.skip_bump == 'true'))
+ run: |
+ bun run scripts/smoke-test-nuget-package.mjs \
+ --package-id "${{ steps.package.outputs.id }}" \
+ --release-version "${{ steps.release_version.outputs.version }}"
+
+ - name: Create GitHub Release
+ if: >-
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true' ||
+ (steps.check_release.outputs.should_release == 'true' &&
+ steps.check_release.outputs.skip_bump == 'true')
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ bun run "${{ steps.csharp_layout.outputs.root }}/scripts/create-github-release.mjs" \
+ --release-version "${{ steps.release_version.outputs.version }}" \
+ --repository "${{ github.repository }}" \
+ --csharp-root "${{ steps.csharp_layout.outputs.root }}" \
+ --language "C#" \
+ --package-id "${{ steps.package.outputs.id }}" \
+ --assets-glob "./artifacts/*.nupkg"
+
+ # === MANUAL INSTANT RELEASE ===
+ # Triggered via workflow_dispatch with instant mode
+ instant-release:
+ name: Instant Release
+ needs: [lint, test, build]
+ # Mirror the automatic release job: a status-check function plus explicit
+ # needs.*.result checks so the dispatch run is evaluated even though
+ # detect-changes (an upstream skip) propagated through the needs graph.
+ if: |
+ always() && !cancelled() &&
+ github.event_name == 'workflow_dispatch' &&
+ github.event.inputs.release_mode == 'instant' &&
+ needs.lint.result == 'success' &&
+ needs.test.result == 'success' &&
+ needs.build.result == 'success'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: write
+ packages: write
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Detect C# layout
+ id: csharp_layout
+ shell: bash
+ run: |
+ set -euo pipefail
+ if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="."
+ MULTI_LANGUAGE="false"
+ elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="csharp"
+ MULTI_LANGUAGE="true"
+ else
+ echo "::error::Could not find a C# project at the repository root or under csharp/"
+ exit 1
+ fi
+ echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT"
+ echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT"
+ echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE"
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Version and commit
+ id: version
+ run: |
+ bun run "${{ steps.csharp_layout.outputs.root }}/scripts/version-and-commit.mjs" \
+ --mode instant \
+ --bump-type "${{ github.event.inputs.bump_type }}" \
+ --description "${{ github.event.inputs.description }}" \
+ --csharp-root "${{ steps.csharp_layout.outputs.root }}"
+
+ - name: Build package
+ if: >-
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true'
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: |
+ dotnet restore
+ dotnet build --configuration Release
+ dotnet pack --no-build --configuration Release --output ./artifacts
+
+ - name: Resolve NuGet package id
+ if: >-
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true'
+ id: package
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: |
+ PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:PackageId | tail -n 1 | tr -d '\r')
+ if [ -z "$PACKAGE_ID" ]; then
+ PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:AssemblyName | tail -n 1 | tr -d '\r')
+ fi
+ if [ -z "$PACKAGE_ID" ]; then
+ PACKAGE_ID="MyPackage"
+ fi
+ echo "id=$PACKAGE_ID" >> "$GITHUB_OUTPUT"
+
+ - name: Validate NuGet API key
+ if: >-
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true'
+ env:
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ run: |
+ if [ -z "$NUGET_API_KEY" ]; then
+ echo "::warning::NUGET_API_KEY is not configured — NuGet publish will be skipped."
+ exit 0
+ fi
+ echo "NUGET_API_KEY length: ${#NUGET_API_KEY}"
+
+ - name: Publish to NuGet
+ id: nuget_publish
+ if: >-
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true'
+ env:
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ working-directory: ${{ steps.csharp_layout.outputs.root }}
+ run: |
+ if [ -n "$NUGET_API_KEY" ]; then
+ dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
+ echo "published=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "NUGET_API_KEY not set, skipping NuGet publish"
+ echo "published=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Wait for NuGet indexing
+ # NuGet's flat-container API can take up to 15 minutes to reflect a
+ # newly pushed package (see issue #13). Poll it via the tested helper
+ # before creating the GitHub release so users can `dotnet add package`
+ # the version mentioned in the release notes.
+ if: >-
+ steps.nuget_publish.outputs.published == 'true' && (
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true')
+ run: |
+ bun run "${{ steps.csharp_layout.outputs.root }}/scripts/wait-for-nuget.mjs" \
+ --package-id "${{ steps.package.outputs.id }}" \
+ --release-version "${{ steps.version.outputs.new_version }}"
+
+ - name: Smoke-test published NuGet package
+ # Indexing proves the version exists; this installs it in a clean
+ # throwaway project and runs the advertised library entry point before
+ # release notes are published. The helper captures command output
+ # before previewing it, avoiding SIGPIPE-prone live-output pagination.
+ if: >-
+ steps.nuget_publish.outputs.published == 'true' && (
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true')
+ run: |
+ bun run scripts/smoke-test-nuget-package.mjs \
+ --package-id "${{ steps.package.outputs.id }}" \
+ --release-version "${{ steps.version.outputs.new_version }}"
+
+ - name: Create GitHub Release
+ if: >-
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ bun run "${{ steps.csharp_layout.outputs.root }}/scripts/create-github-release.mjs" \
+ --release-version "${{ steps.version.outputs.new_version }}" \
+ --repository "${{ github.repository }}" \
+ --csharp-root "${{ steps.csharp_layout.outputs.root }}" \
+ --language "C#" \
+ --package-id "${{ steps.package.outputs.id }}" \
+ --assets-glob "./artifacts/*.nupkg"
+
+ # === MANUAL CHANGESET PR ===
+ # Creates a pull request with the changeset for review
+ changeset-pr:
+ name: Create Changeset PR
+ if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Detect C# layout
+ id: csharp_layout
+ shell: bash
+ run: |
+ set -euo pipefail
+ if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="."
+ MULTI_LANGUAGE="false"
+ elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then
+ CSHARP_ROOT="csharp"
+ MULTI_LANGUAGE="true"
+ else
+ echo "::error::Could not find a C# project at the repository root or under csharp/"
+ exit 1
+ fi
+ echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT"
+ echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT"
+ echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE"
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Create changeset file
+ run: |
+ CHANGESET_ID=$(date +%s)
+ CHANGESET_DIR="${{ steps.csharp_layout.outputs.root }}/.changeset"
+ mkdir -p "$CHANGESET_DIR"
+ CHANGESET_FILE="$CHANGESET_DIR/manual-release-${CHANGESET_ID}.md"
+
+ cat > "$CHANGESET_FILE" << 'EOF'
+ ---
+ 'MyPackage': ${{ github.event.inputs.bump_type }}
+ ---
+
+ ${{ github.event.inputs.description || 'Manual release' }}
+ EOF
+
+ echo "Created changeset: $CHANGESET_FILE"
+
+ - name: Create Pull Request
+ uses: peter-evans/create-pull-request@v8
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release'
+ branch: changeset-manual-release-${{ github.run_id }}
+ delete-branch: true
+ title: 'chore: manual ${{ github.event.inputs.bump_type }} release'
+ body: |
+ ## Manual Release Request
+
+ This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release.
+
+ ### Release Details
+ - **Type:** ${{ github.event.inputs.bump_type }}
+ - **Description:** ${{ github.event.inputs.description || 'Manual release' }}
+ - **Triggered by:** @${{ github.actor }}
+
+ ### Next Steps
+ 1. Review the changeset in this PR
+ 2. Merge this PR to main
+ 3. The automated release workflow will version, publish, and create a GitHub release
diff --git a/dev/log/issues/298/pulls/299/templates/csharp/security.yml b/dev/log/issues/298/pulls/299/templates/csharp/security.yml
new file mode 100644
index 00000000..80b2b935
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/csharp/security.yml
@@ -0,0 +1,49 @@
+name: Security
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ schedule:
+ - cron: '0 6 * * 1'
+
+permissions:
+ contents: read
+
+jobs:
+ codeql:
+ name: CodeQL (${{ matrix.language }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }}
+ cancel-in-progress: true
+ permissions:
+ contents: read
+ security-events: write
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [csharp, actions]
+ steps:
+ - uses: actions/checkout@v6
+ - uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+ - uses: github/codeql-action/autobuild@v4
+ - uses: github/codeql-action/analyze@v4
+
+ dependency-review:
+ name: Dependency Review
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/dependency-review-action@v5
+ with:
+ fail-on-severity: high
+ comment-summary-in-pr: on-failure
diff --git a/dev/log/issues/298/pulls/299/templates/csharp/workflows.yml b/dev/log/issues/298/pulls/299/templates/csharp/workflows.yml
new file mode 100644
index 00000000..a81031e6
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/csharp/workflows.yml
@@ -0,0 +1,44 @@
+name: Workflows
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - '.github/workflows/**'
+ - '.github/actions/**'
+ pull_request:
+ types: [opened, synchronize, reopened]
+ paths:
+ - '.github/workflows/**'
+ - '.github/actions/**'
+ workflow_dispatch:
+
+# Least-privilege default; this workflow only ever reads the tree.
+permissions:
+ contents: read
+
+jobs:
+ actionlint:
+ name: Lint Workflows
+ runs-on: ubuntu-latest
+ # Typical run: <1min. 10min prevents an image pull stall from hanging.
+ timeout-minutes: 10
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-actionlint
+ cancel-in-progress: true
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ # The Docker image bundles shellcheck and pyflakes, so this also lints
+ # every `run:` block - that is what catches script injection sinks such as
+ # `${{ github.head_ref }}` pasted into a shell script (issue #49).
+ # A *native* actionlint binary without shellcheck on PATH silently skips
+ # the shell checks and exits 0, so keep using the Docker image here.
+ - uses: docker://rhysd/actionlint:1.7.7
+ with:
+ args: -color
diff --git a/dev/log/issues/298/pulls/299/templates/go-workflows.txt b/dev/log/issues/298/pulls/299/templates/go-workflows.txt
new file mode 100644
index 00000000..0b255060
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/go-workflows.txt
@@ -0,0 +1 @@
+release.yml
diff --git a/dev/log/issues/298/pulls/299/templates/go/release.yml b/dev/log/issues/298/pulls/299/templates/go/release.yml
new file mode 100644
index 00000000..b84b0588
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/go/release.yml
@@ -0,0 +1,339 @@
+name: CI/CD Pipeline
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+ workflow_dispatch:
+ inputs:
+ release_mode:
+ description: 'Release mode'
+ required: true
+ type: choice
+ default: 'instant'
+ options:
+ - instant
+ - changeset
+ bump_type:
+ description: 'Version bump type (for instant mode)'
+ required: true
+ type: choice
+ options:
+ - patch
+ - minor
+ - major
+ description:
+ description: 'Release description (optional)'
+ required: false
+ type: string
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ # === DETECT CHANGES - determines which jobs should run ===
+ detect-changes:
+ name: Detect Changes
+ runs-on: ubuntu-latest
+ if: github.event_name != 'workflow_dispatch'
+ outputs:
+ go-changed: ${{ steps.changes.outputs.go-changed }}
+ mod-changed: ${{ steps.changes.outputs.mod-changed }}
+ mjs-changed: ${{ steps.changes.outputs.mjs-changed }}
+ docs-changed: ${{ steps.changes.outputs.docs-changed }}
+ workflow-changed: ${{ steps.changes.outputs.workflow-changed }}
+ any-code-changed: ${{ steps.changes.outputs.any-code-changed }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Detect changes
+ id: changes
+ env:
+ GITHUB_EVENT_NAME: ${{ github.event_name }}
+ GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: bun scripts/detect-code-changes.mjs
+
+ # === CHANGESET CHECK - only runs on PRs with code changes ===
+ # Docs-only PRs (./docs folder, markdown files) don't require changesets
+ changeset-check:
+ name: Check for Changeset
+ runs-on: ubuntu-latest
+ needs: [detect-changes]
+ if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Check for changeset
+ env:
+ GITHUB_BASE_REF: ${{ github.base_ref }}
+ GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: bun scripts/validate-changeset.mjs
+
+ # === LINT AND FORMAT CHECK ===
+ # Lint runs independently of changeset-check - it's a fast check that should always run
+ # See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ needs: [detect-changes]
+ if: |
+ github.event_name == 'push' ||
+ github.event_name == 'workflow_dispatch' ||
+ needs.detect-changes.outputs.go-changed == 'true' ||
+ needs.detect-changes.outputs.mod-changed == 'true' ||
+ needs.detect-changes.outputs.mjs-changed == 'true' ||
+ needs.detect-changes.outputs.docs-changed == 'true' ||
+ needs.detect-changes.outputs.workflow-changed == 'true'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.21'
+ cache: false
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Check formatting
+ run: |
+ if [ -n "$(gofmt -l .)" ]; then
+ echo "The following files are not formatted correctly:"
+ gofmt -l .
+ exit 1
+ fi
+
+ - name: Run go vet
+ run: go vet ./...
+
+ - name: Install staticcheck
+ run: go install honnef.co/go/tools/cmd/staticcheck@latest
+
+ - name: Run staticcheck
+ run: staticcheck ./...
+
+ - name: Check file sizes
+ run: bun scripts/check-file-size.mjs
+
+ # === TEST ===
+ test:
+ name: Test (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ needs: [detect-changes, changeset-check]
+ # Run if: push event, workflow_dispatch, OR changeset-check succeeded, OR changeset-check was skipped (docs-only PR)
+ if: always() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped')
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.21'
+ cache: false
+
+ - name: Run tests (Unix)
+ if: matrix.os != 'windows-latest'
+ run: go test -v -race -coverprofile=coverage.out ./...
+
+ - name: Run tests (Windows)
+ if: matrix.os == 'windows-latest'
+ run: go test -v -race ./...
+
+ - name: Upload coverage (Ubuntu only)
+ if: matrix.os == 'ubuntu-latest'
+ uses: codecov/codecov-action@v4
+ with:
+ files: ./coverage.out
+ fail_ci_if_error: false
+ continue-on-error: true
+
+ # === BUILD ===
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ needs: [lint, test]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.21'
+ cache: false
+
+ - name: Build package
+ run: go build ./...
+
+ - name: Build example
+ run: go build -o example ./examples/basic_usage.go
+
+ # === AUTO RELEASE - runs on main when PRs with changesets are merged ===
+ auto-release:
+ name: Auto Release
+ runs-on: ubuntu-latest
+ needs: [lint, test, build]
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Check for changesets
+ id: check_changesets
+ run: |
+ # Count changeset files (excluding README.md)
+ CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l)
+ echo "Found $CHANGESET_COUNT changeset file(s)"
+ echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT
+ echo "changeset_count=$CHANGESET_COUNT" >> $GITHUB_OUTPUT
+
+ - name: Merge multiple changesets
+ if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1
+ run: |
+ echo "Multiple changesets detected, merging..."
+ bun scripts/merge-changesets.mjs
+
+ - name: Configure git
+ if: steps.check_changesets.outputs.has_changesets == 'true'
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Version and release
+ if: steps.check_changesets.outputs.has_changesets == 'true'
+ id: version
+ run: bun scripts/version-and-commit.mjs --mode changeset
+ env:
+ CI: true
+
+ - name: Create GitHub Release
+ if: steps.check_changesets.outputs.has_changesets == 'true' && steps.version.outputs.version_committed == 'true'
+ run: bun scripts/create-github-release.mjs --version ${{ steps.version.outputs.new_version }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # === MANUAL INSTANT RELEASE ===
+ instant-release:
+ name: Instant Release
+ runs-on: ubuntu-latest
+ needs: [lint, test, build]
+ if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Configure git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Version and release
+ id: version
+ run: bun scripts/version-and-commit.mjs --mode instant --bump-type ${{ github.event.inputs.bump_type }} --description "${{ github.event.inputs.description }}"
+ env:
+ CI: true
+
+ - name: Create GitHub Release
+ if: steps.version.outputs.version_committed == 'true'
+ run: bun scripts/create-github-release.mjs --version ${{ steps.version.outputs.new_version }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # === MANUAL CHANGESET RELEASE - processes existing changesets ===
+ changeset-release:
+ name: Changeset Release
+ runs-on: ubuntu-latest
+ needs: [lint, test, build]
+ if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Check for changesets
+ id: check_changesets
+ run: |
+ CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l)
+ echo "Found $CHANGESET_COUNT changeset file(s)"
+ if [ "$CHANGESET_COUNT" -eq 0 ]; then
+ echo "No changesets found to release"
+ exit 1
+ fi
+ echo "changeset_count=$CHANGESET_COUNT" >> $GITHUB_OUTPUT
+
+ - name: Merge multiple changesets
+ if: steps.check_changesets.outputs.changeset_count > 1
+ run: bun scripts/merge-changesets.mjs
+
+ - name: Configure git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Version and release
+ id: version
+ run: bun scripts/version-and-commit.mjs --mode changeset
+ env:
+ CI: true
+
+ - name: Create GitHub Release
+ if: steps.version.outputs.version_committed == 'true'
+ run: bun scripts/create-github-release.mjs --version ${{ steps.version.outputs.new_version }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/dev/log/issues/298/pulls/299/templates/java-workflows.txt b/dev/log/issues/298/pulls/299/templates/java-workflows.txt
new file mode 100644
index 00000000..0b255060
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/java-workflows.txt
@@ -0,0 +1 @@
+release.yml
diff --git a/dev/log/issues/298/pulls/299/templates/java/release.yml b/dev/log/issues/298/pulls/299/templates/java/release.yml
new file mode 100644
index 00000000..8c593a4f
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/java/release.yml
@@ -0,0 +1,457 @@
+name: CI/CD Pipeline
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+ workflow_dispatch:
+ inputs:
+ release_mode:
+ description: 'Release mode'
+ required: true
+ default: 'changeset'
+ type: choice
+ options:
+ - changeset
+ - instant
+ - changeset-pr
+ bump_type:
+ description: 'Version bump type (for instant mode or changeset-pr)'
+ required: false
+ default: 'patch'
+ type: choice
+ options:
+ - patch
+ - minor
+ - major
+ description:
+ description: 'Change description (for changeset-pr mode)'
+ required: false
+ type: string
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ JAVA_VERSION: '21'
+ JAVA_DISTRIBUTION: 'temurin'
+
+jobs:
+ # =============================================================================
+ # Detect Changes - determines which jobs should run
+ # =============================================================================
+ detect-changes:
+ name: Detect Changes
+ runs-on: ubuntu-latest
+ if: github.event_name != 'workflow_dispatch'
+ outputs:
+ java-changed: ${{ steps.changes.outputs.java-changed }}
+ pom-changed: ${{ steps.changes.outputs.pom-changed }}
+ mjs-changed: ${{ steps.changes.outputs.mjs-changed }}
+ docs-changed: ${{ steps.changes.outputs.docs-changed }}
+ workflow-changed: ${{ steps.changes.outputs.workflow-changed }}
+ any-code-changed: ${{ steps.changes.outputs.any-code-changed }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Detect changes
+ id: changes
+ env:
+ GITHUB_EVENT_NAME: ${{ github.event_name }}
+ GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: bun scripts/detect-code-changes.mjs
+
+ # =============================================================================
+ # Lint and Format Check
+ # Runs independently of changeset-check - it's a fast check that should always run
+ # See: https://github.com/link-foundation/js-ai-driven-development-pipeline-template/pull/18
+ # =============================================================================
+ lint:
+ name: Lint & Format
+ runs-on: ubuntu-latest
+ needs: [detect-changes]
+ if: |
+ github.event_name == 'workflow_dispatch' ||
+ github.event_name == 'push' ||
+ needs.detect-changes.outputs.java-changed == 'true' ||
+ needs.detect-changes.outputs.pom-changed == 'true' ||
+ needs.detect-changes.outputs.mjs-changed == 'true' ||
+ needs.detect-changes.outputs.docs-changed == 'true' ||
+ needs.detect-changes.outputs.workflow-changed == 'true'
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up JDK ${{ env.JAVA_VERSION }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ env.JAVA_VERSION }}
+ distribution: ${{ env.JAVA_DISTRIBUTION }}
+ cache: maven
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Check code formatting (Spotless)
+ run: mvn spotless:check -B
+
+ - name: Run SpotBugs static analysis
+ run: mvn spotbugs:check -B
+
+ - name: Check file sizes
+ run: bun scripts/check-file-size.mjs
+
+ # =============================================================================
+ # Build and Test
+ # =============================================================================
+ test:
+ name: Test (Java ${{ matrix.java }}, ${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ needs: [detect-changes, changeset-check]
+ # Run if: push event, workflow_dispatch, OR changeset-check succeeded, OR changeset-check was skipped (docs-only PR)
+ if: always() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped')
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ java: ['21']
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up JDK ${{ matrix.java }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ matrix.java }}
+ distribution: ${{ env.JAVA_DISTRIBUTION }}
+ cache: maven
+
+ - name: Run tests with coverage
+ run: mvn test jacoco:report -B
+
+ - name: Upload coverage to Codecov
+ if: matrix.os == 'ubuntu-latest' && matrix.java == '21'
+ uses: codecov/codecov-action@v4
+ with:
+ file: target/site/jacoco/jacoco.xml
+ flags: unittests
+ fail_ci_if_error: false
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+
+ # =============================================================================
+ # Build Package
+ # =============================================================================
+ build:
+ name: Build Package
+ runs-on: ubuntu-latest
+ needs: [lint, test]
+ # Run only if lint and test succeeded (handles skipped lint gracefully)
+ if: always() && (needs.lint.result == 'success' || needs.lint.result == 'skipped') && needs.test.result == 'success'
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up JDK ${{ env.JAVA_VERSION }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ env.JAVA_VERSION }}
+ distribution: ${{ env.JAVA_DISTRIBUTION }}
+ cache: maven
+
+ - name: Build with Maven
+ run: mvn package -DskipTests -B
+
+ - name: Verify JAR contents
+ run: |
+ echo "=== JAR contents ==="
+ jar tf target/*.jar | head -50
+ echo "..."
+
+ - name: Upload build artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-artifacts
+ path: |
+ target/*.jar
+ target/site/jacoco/
+ retention-days: 7
+
+ # =============================================================================
+ # Changeset Validation (PRs only, skipped for docs-only changes)
+ # Docs-only PRs (./docs folder, markdown files) don't require changesets
+ # =============================================================================
+ changeset-check:
+ name: Changeset Check
+ runs-on: ubuntu-latest
+ needs: [detect-changes]
+ if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true'
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Validate changeset
+ run: bun scripts/validate-changeset.mjs
+ env:
+ GITHUB_BASE_REF: ${{ github.base_ref }}
+
+ # =============================================================================
+ # Auto Release (on push to main with changesets)
+ # =============================================================================
+ auto-release:
+ name: Auto Release
+ runs-on: ubuntu-latest
+ needs: [build]
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up JDK ${{ env.JAVA_VERSION }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ env.JAVA_VERSION }}
+ distribution: ${{ env.JAVA_DISTRIBUTION }}
+ cache: maven
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Check for changesets
+ id: changesets
+ run: |
+ CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l)
+ echo "count=$CHANGESET_COUNT" >> $GITHUB_OUTPUT
+ if [ "$CHANGESET_COUNT" -gt 0 ]; then
+ echo "has_changesets=true" >> $GITHUB_OUTPUT
+ echo "Found $CHANGESET_COUNT changeset(s)"
+ else
+ echo "has_changesets=false" >> $GITHUB_OUTPUT
+ echo "No changesets found"
+ fi
+
+ - name: Merge changesets (if multiple)
+ if: steps.changesets.outputs.has_changesets == 'true' && steps.changesets.outputs.count > 1
+ run: bun scripts/merge-changesets.mjs
+
+ - name: Run version and commit script
+ if: steps.changesets.outputs.has_changesets == 'true'
+ id: release
+ run: bun scripts/version-and-commit.mjs --mode changeset
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build release artifacts
+ if: steps.release.outputs.released == 'true'
+ run: mvn package source:jar javadoc:jar -DskipTests -B
+
+ - name: Create GitHub Release
+ if: steps.release.outputs.released == 'true'
+ run: |
+ bun scripts/create-github-release.mjs \
+ --release-version "${{ steps.release.outputs.new_version }}" \
+ --repository "${{ github.repository }}"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload release artifacts
+ if: steps.release.outputs.released == 'true'
+ run: |
+ TAG="v${{ steps.release.outputs.new_version }}"
+ gh release upload "$TAG" target/*.jar --clobber || true
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # =============================================================================
+ # Manual Release - Changeset Mode (workflow dispatch)
+ # =============================================================================
+ manual-release-changeset:
+ name: Manual Release (Changeset)
+ runs-on: ubuntu-latest
+ needs: [build]
+ if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset'
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up JDK ${{ env.JAVA_VERSION }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ env.JAVA_VERSION }}
+ distribution: ${{ env.JAVA_DISTRIBUTION }}
+ cache: maven
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Merge changesets (if multiple)
+ run: bun scripts/merge-changesets.mjs
+
+ - name: Run version and commit script
+ id: release
+ run: bun scripts/version-and-commit.mjs --mode changeset
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build release artifacts
+ if: steps.release.outputs.released == 'true'
+ run: mvn package source:jar javadoc:jar -DskipTests -B
+
+ - name: Create GitHub Release
+ if: steps.release.outputs.released == 'true'
+ run: |
+ bun scripts/create-github-release.mjs \
+ --release-version "${{ steps.release.outputs.new_version }}" \
+ --repository "${{ github.repository }}"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload release artifacts
+ if: steps.release.outputs.released == 'true'
+ run: |
+ TAG="v${{ steps.release.outputs.new_version }}"
+ gh release upload "$TAG" target/*.jar --clobber || true
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # =============================================================================
+ # Manual Release - Instant Mode (workflow dispatch)
+ # =============================================================================
+ manual-release-instant:
+ name: Manual Release (Instant)
+ runs-on: ubuntu-latest
+ needs: [build]
+ if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant'
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up JDK ${{ env.JAVA_VERSION }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ env.JAVA_VERSION }}
+ distribution: ${{ env.JAVA_DISTRIBUTION }}
+ cache: maven
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Run version and commit script
+ id: release
+ run: bun scripts/version-and-commit.mjs --mode instant --bump-type ${{ inputs.bump_type }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build release artifacts
+ if: steps.release.outputs.released == 'true'
+ run: mvn package source:jar javadoc:jar -DskipTests -B
+
+ - name: Create GitHub Release
+ if: steps.release.outputs.released == 'true'
+ run: |
+ bun scripts/create-github-release.mjs \
+ --release-version "${{ steps.release.outputs.new_version }}" \
+ --repository "${{ github.repository }}"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload release artifacts
+ if: steps.release.outputs.released == 'true'
+ run: |
+ TAG="v${{ steps.release.outputs.new_version }}"
+ gh release upload "$TAG" target/*.jar --clobber || true
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # =============================================================================
+ # Manual Changeset PR (workflow dispatch)
+ # =============================================================================
+ changeset-pr:
+ name: Create Changeset PR
+ runs-on: ubuntu-latest
+ if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr'
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Create changeset file
+ run: |
+ DESCRIPTION="${{ inputs.description }}"
+ if [ -z "$DESCRIPTION" ]; then
+ DESCRIPTION="Manual release"
+ fi
+ bun scripts/create-manual-changeset.mjs \
+ --bump-type "${{ inputs.bump_type }}" \
+ --description "$DESCRIPTION"
+
+ - name: Create Pull Request
+ uses: peter-evans/create-pull-request@v6
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ commit-message: "chore: add changeset for ${{ inputs.bump_type }} release"
+ branch: changeset-manual-release-${{ github.run_id }}
+ delete-branch: true
+ title: "chore: ${{ inputs.bump_type }} release changeset"
+ body: |
+ ## Summary
+
+ This PR adds a changeset for a **${{ inputs.bump_type }}** release.
+
+ ${{ inputs.description }}
+
+ ---
+ *This PR was automatically created by the changeset-pr workflow.*
+ labels: |
+ release
+ automated
diff --git a/dev/log/issues/298/pulls/299/templates/js-workflows.txt b/dev/log/issues/298/pulls/299/templates/js-workflows.txt
new file mode 100644
index 00000000..59335ffa
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/js-workflows.txt
@@ -0,0 +1,5 @@
+example-app.yml
+links.yml
+release.yml
+security.yml
+workflows.yml
diff --git a/dev/log/issues/298/pulls/299/templates/js/example-app.yml b/dev/log/issues/298/pulls/299/templates/js/example-app.yml
new file mode 100644
index 00000000..649702a8
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/js/example-app.yml
@@ -0,0 +1,318 @@
+name: Example app
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+ paths:
+ - 'examples/universal-app/**'
+ - 'src/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'scripts/update-preview-images.mjs'
+ - '.github/workflows/example-app.yml'
+ push:
+ branches:
+ - main
+ paths:
+ - 'examples/universal-app/**'
+ - 'src/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'scripts/update-preview-images.mjs'
+ - '.github/workflows/example-app.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+# Provide Git config to actions/checkout itself; checkout runs git init before
+# any workflow step can configure Git.
+env:
+ GIT_CONFIG_COUNT: '1'
+ GIT_CONFIG_KEY_0: init.defaultBranch
+ GIT_CONFIG_VALUE_0: main
+
+jobs:
+ web-build:
+ name: Build web app
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-web-build
+ cancel-in-progress: true
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24.x'
+ cache: npm
+ cache-dependency-path: examples/universal-app/package-lock.json
+
+ - name: Configure GitHub Pages
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ uses: actions/configure-pages@v6
+
+ - name: Install app dependencies
+ run: npm ci --prefix examples/universal-app --no-audit --no-fund
+
+ - name: Build app
+ run: npm run example:web:build
+ env:
+ GITHUB_PAGES: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
+ VITE_REPOSITORY_URL: https://github.com/${{ github.repository }}
+
+ - name: Upload web build artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: universal-example-web
+ path: examples/universal-app/dist
+ if-no-files-found: error
+
+ - name: Upload GitHub Pages artifact
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ uses: actions/upload-pages-artifact@v5
+ with:
+ path: examples/universal-app/dist
+
+ # Requires Settings → Pages → Source = GitHub Actions to be set once
+ # in the repository before this job can succeed. See README → "Deploying
+ # the example app". The Pages source cannot be configured from a workflow.
+ pages-deploy:
+ name: Deploy GitHub Pages
+ needs: [web-build]
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ concurrency:
+ group: main-writer-${{ github.repository }}-main
+ cancel-in-progress: false
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy Pages artifact
+ id: deployment
+ uses: actions/deploy-pages@v5
+
+ desktop-package:
+ name: Package desktop app (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 20
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-desktop-package-${{ matrix.os }}
+ cancel-in-progress: true
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24.x'
+ cache: npm
+ cache-dependency-path: examples/universal-app/package-lock.json
+
+ - name: Install app dependencies
+ run: npm ci --prefix examples/universal-app --no-audit --no-fund
+
+ - name: Package Electron app
+ shell: bash
+ run: |
+ npm run example:desktop:package
+ for attempt in {1..30}; do
+ echo "Waiting for the packaged app (attempt ${attempt}/30)"
+ if [[ -d examples/universal-app/out ]] &&
+ [[ -n "$(find examples/universal-app/out -mindepth 1 -print -quit)" ]]; then
+ find examples/universal-app/out -maxdepth 2 -mindepth 1 -print
+ exit 0
+ fi
+ sleep 1
+ done
+ echo "::error::Desktop package output was not created at examples/universal-app/out"
+ exit 1
+ env:
+ VITE_REPOSITORY_URL: https://github.com/${{ github.repository }}
+
+ - name: Upload desktop package
+ uses: actions/upload-artifact@v7
+ with:
+ name: universal-example-desktop-${{ matrix.os }}
+ path: examples/universal-app/out
+ if-no-files-found: error
+
+ android-build:
+ name: Build Android app
+ if: github.event_name == 'workflow_dispatch' && vars.EXAMPLE_APP_ENABLE_ANDROID_BUILD == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-android-build
+ cancel-in-progress: true
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24.x'
+ cache: npm
+ cache-dependency-path: examples/universal-app/package-lock.json
+
+ - name: Setup Java
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: '21'
+
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2
+
+ - name: Install app dependencies
+ run: npm ci --prefix examples/universal-app --no-audit --no-fund
+
+ - name: Add Android project
+ run: npm --prefix examples/universal-app run mobile:android:add
+
+ - name: Build Android project
+ run: npm --prefix examples/universal-app run mobile:android:build
+
+ - name: Upload Android output
+ uses: actions/upload-artifact@v7
+ with:
+ name: universal-example-android
+ path: |
+ examples/universal-app/android/app/build/outputs/**/*.apk
+ examples/universal-app/android/app/build/outputs/**/*.aab
+ if-no-files-found: warn
+
+ ios-build:
+ name: Build iOS app
+ if: github.event_name == 'workflow_dispatch' && vars.EXAMPLE_APP_ENABLE_IOS_BUILD == 'true'
+ runs-on: macos-latest
+ timeout-minutes: 30
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-ios-build
+ cancel-in-progress: true
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24.x'
+ cache: npm
+ cache-dependency-path: examples/universal-app/package-lock.json
+
+ - name: Install app dependencies
+ run: npm ci --prefix examples/universal-app --no-audit --no-fund
+
+ - name: Add iOS project
+ run: npm --prefix examples/universal-app run mobile:ios:add
+
+ - name: Build iOS project
+ run: npm --prefix examples/universal-app run mobile:ios:build
+
+ # Regenerate example-app preview screenshots (docs/screenshots/example-app/*)
+ # using browser-commander + Playwright so README/site images always reflect
+ # the current UI. Issue: #62. Implementation: scripts/update-preview-images.mjs.
+ preview-regen:
+ name: Regenerate Preview Images
+ runs-on: ubuntu-latest
+ container:
+ # Keep this tag in sync with the playwright package version below.
+ image: mcr.microsoft.com/playwright:v1.59.1-noble
+ timeout-minutes: 20
+ concurrency:
+ group: main-writer-${{ github.repository }}-main
+ cancel-in-progress: false
+ if: |
+ (github.event_name == 'push' && github.ref == 'refs/heads/main') ||
+ github.event_name == 'workflow_dispatch'
+ permissions:
+ contents: write
+ # The push helper opens and merges a pull request when a repository
+ # ruleset declines the direct push to main (issue #143).
+ pull-requests: write
+ env:
+ PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ # Regenerate against main HEAD so the bot commit lands on a
+ # fast-forward parent regardless of which trigger started the job.
+ ref: main
+ token: ${{ secrets.GITHUB_TOKEN }}
+ fetch-depth: 0
+
+ - name: Install example app dependencies
+ run: npm ci --prefix examples/universal-app --no-audit --no-fund
+
+ # Deliberately installed outside the lockfile: these tools are only used
+ # by this preview-image job and both versions are pinned exactly.
+ - name: Install browser automation dependencies
+ env:
+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
+ run: npm install --no-save --package-lock=false --no-audit --no-fund browser-commander@0.8.1 playwright@1.59.1 # zizmor: ignore[adhoc-packages]
+
+ - name: Regenerate preview images
+ run: node scripts/update-preview-images.mjs
+
+ - name: Detect drift
+ id: drift
+ run: |
+ if [[ -n "$(git status --porcelain)" ]]; then
+ echo "drift=true" >> "$GITHUB_OUTPUT"
+ echo "Preview images drifted:"
+ git status --porcelain
+ else
+ echo "drift=false" >> "$GITHUB_OUTPUT"
+ echo "Preview images already current."
+ fi
+
+ - name: Commit drift back to main
+ if: steps.drift.outputs.drift == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ # Stage only generated artifacts so unrelated changes can't leak in.
+ git add docs/screenshots/example-app/*.png || true
+ if git diff --cached --quiet; then
+ echo "::notice::No tracked preview-image changes to commit (drift was outside expected paths)."
+ git status --porcelain
+ exit 0
+ fi
+ # [skip ci] prevents an infinite re-run loop; the next push to main
+ # will pick up these fresh images for the regular pages-build.
+ git commit -m "chore(preview): regenerate example-app preview images [skip ci]"
+ node scripts/push-main-with-rebase-retry.mjs origin main --label preview-images
+
+ - name: Upload screenshot failure artifacts
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: preview-regen-failure-${{ github.run_id }}
+ path: |
+ docs/screenshots/
+ web/test-results/
+ web/playwright-report/
+ retention-days: 7
+ if-no-files-found: ignore
+
+ - name: Summarize regeneration result
+ if: always()
+ run: |
+ if [[ "${{ steps.drift.outputs.drift }}" == "true" ]]; then
+ echo "::notice::Preview images regenerated and (if applicable) committed to main."
+ else
+ echo "::notice::Preview images are already up to date."
+ fi
diff --git a/dev/log/issues/298/pulls/299/templates/js/links.yml b/dev/log/issues/298/pulls/299/templates/js/links.yml
new file mode 100644
index 00000000..5ff315d0
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/js/links.yml
@@ -0,0 +1,104 @@
+name: Broken Link Checker
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - '**.md'
+ - '**.html'
+ - '.github/workflows/links.yml'
+ pull_request:
+ types: [opened, synchronize, reopened]
+ paths:
+ - '**.md'
+ - '**.html'
+ - '.github/workflows/links.yml'
+ workflow_dispatch:
+
+# Least-privilege default; jobs escalate individually when needed.
+permissions:
+ contents: read
+
+# Provide Git config to actions/checkout itself; checkout runs git init before
+# any workflow step can configure Git.
+env:
+ GIT_CONFIG_COUNT: '1'
+ GIT_CONFIG_KEY_0: init.defaultBranch
+ GIT_CONFIG_VALUE_0: main
+
+jobs:
+ link-checker:
+ name: Check Links
+ runs-on: ubuntu-latest
+ # Typical run: <1min with lychee cache. 10min prevents slow
+ # external hosts or Wayback Machine probes from hanging the workflow.
+ timeout-minutes: 10
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-link-checker
+ cancel-in-progress: true
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Check links with lychee
+ id: lychee
+ uses: lycheeverse/lychee-action@v2
+ with:
+ # Check all Markdown and HTML files
+ # Exclude case-studies directory - these are research documents from
+ # external repos with references to files and issues that don't exist
+ # in this repository (similar exclusion pattern as eslint.config.js)
+ # Exclude the Vite source HTML because its root-relative app asset
+ # URLs are only valid when served by Vite.
+ # Exclude tests/fixtures - the captured lychee reports there contain
+ # deliberately broken links used as parser test input.
+ args: >-
+ --verbose
+ --no-progress
+ --cache
+ --max-cache-age 1d
+ --max-retries 3
+ --timeout 30
+ --exclude-path docs/case-studies
+ --exclude-path examples/universal-app/index.html
+ --exclude-path tests/fixtures
+ './**/*.md'
+ './**/*.html'
+ # Don't fail the workflow immediately - we want to check web archive first
+ fail: false
+ # Output file for broken links report (used by check-web-archive.mjs)
+ output: lychee/out.md
+ # Write a job summary
+ jobSummary: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Check broken links against Web Archive
+ if: steps.lychee.outputs.exit_code != 0
+ id: webarchive
+ run: node scripts/check-web-archive.mjs
+ env:
+ LYCHEE_OUTPUT: lychee/out.md
+
+ - name: Fail if broken links were found
+ if: always() && steps.lychee.outputs.exit_code != 0
+ run: |
+ echo "::error::Broken live links were detected."
+ echo ""
+ echo "What happened:"
+ echo " lychee found one or more broken links in the *.md and *.html files of this repository."
+ echo " An archive is a suggested replacement; it does not make the live link valid."
+ echo ""
+ echo "How to fix:"
+ echo " 1. Review the 'Check links with lychee' step above for a full list of broken links."
+ echo " 2. For links marked with a '::notice::' annotation above, a Web Archive replacement exists."
+ echo " Replace those broken links with the suggested archive.org URL."
+ echo " 3. For links with no archive version, either:"
+ echo " a. Find an updated URL that points to the same or equivalent content."
+ echo " b. Remove the link if the content is no longer relevant."
+ echo " c. Add the URL to .lycheeignore if it is a known false positive."
+ echo ""
+ echo "Report location: lychee/out.md (available as a workflow artifact if configured)."
+ exit 1
diff --git a/dev/log/issues/298/pulls/299/templates/js/release.yml b/dev/log/issues/298/pulls/299/templates/js/release.yml
new file mode 100644
index 00000000..98c58adf
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/js/release.yml
@@ -0,0 +1,890 @@
+name: Checks and release
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ types: [opened, synchronize, reopened]
+ # Manual release support - consolidated here to work with npm trusted publishing
+ # npm only allows ONE workflow file as trusted publisher, so all publishing
+ # must go through this workflow (release.yml)
+ workflow_dispatch:
+ inputs:
+ release_mode:
+ description: 'Manual release mode'
+ required: true
+ type: choice
+ default: 'instant'
+ options:
+ - instant
+ - changeset-pr
+ bump_type:
+ description: 'Manual release type'
+ required: true
+ type: choice
+ options:
+ - patch
+ - minor
+ - major
+ description:
+ description: 'Manual release description (optional)'
+ required: false
+ type: string
+
+# Least-privilege default for the highest-value token in the repository.
+# Every job starts read-only; the publishing jobs escalate individually.
+permissions:
+ contents: read
+
+# Provide Git config to actions/checkout itself; checkout runs git init before
+# any workflow step can configure Git.
+env:
+ GIT_CONFIG_COUNT: '1'
+ GIT_CONFIG_KEY_0: init.defaultBranch
+ GIT_CONFIG_VALUE_0: main
+
+jobs:
+ # === DETECT CHANGES - determines which jobs should run ===
+ detect-changes:
+ name: Detect Changes
+ runs-on: ubuntu-latest
+ # Typical run: ~6s. Cap at 5min so a hung detection step
+ # surfaces quickly instead of stalling the whole pipeline.
+ timeout-minutes: 5
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-detect-changes
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ if: github.event_name != 'workflow_dispatch'
+ outputs:
+ js-changed: ${{ steps.changes.outputs.js-changed }}
+ docs-changed: ${{ steps.changes.outputs.docs-changed }}
+ any-code-changed: ${{ steps.changes.outputs.any-code-changed }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Detect changes
+ id: changes
+ run: node scripts/detect-code-changes.mjs
+
+ # === FAST CHECKS - run before slow tests for fastest feedback ===
+ # See: hive-mind CI/CD best practices principle #5 (fast-fail job ordering)
+
+ # Syntax check all .mjs files with node --check (~7s)
+ test-compilation:
+ name: Test Compilation
+ runs-on: ubuntu-latest
+ # Typical run: <10s. Tight cap fails fast on syntax-check hangs.
+ timeout-minutes: 5
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-test-compilation
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ needs: [detect-changes]
+ if: |
+ needs.detect-changes.outputs.js-changed == 'true'
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Check .mjs syntax
+ run: bash scripts/check-mjs-syntax.sh
+
+ # Enforce 1500-line limit on .mjs files and release.yml
+ check-file-line-limits:
+ name: Check File Line Limits
+ runs-on: ubuntu-latest
+ # Typical run: <10s. This job only walks tracked files and counts lines.
+ timeout-minutes: 5
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-check-file-line-limits
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ needs: [detect-changes]
+ if: |
+ needs.detect-changes.outputs.docs-changed == 'true' ||
+ needs.detect-changes.outputs.any-code-changed == 'true'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Simulate fresh merge with base branch (PR only)
+ if: github.event_name == 'pull_request'
+ env:
+ BASE_REF: ${{ github.base_ref }}
+ run: bash scripts/simulate-fresh-merge.sh
+
+ # Enforces the 1500-line limit on JavaScript (.js/.mjs/.cjs) and
+ # Markdown (.md) files plus release.yml. This is the single source
+ # of truth for the line limit; validate-docs no longer re-checks it.
+ - name: Check file line limits
+ run: bash scripts/check-file-line-limits.sh
+
+ # === VERSION CHANGE CHECK ===
+ # Prohibit manual version changes in package.json - versions should only be changed by CI/CD
+ version-check:
+ name: Check for Manual Version Changes
+ runs-on: ubuntu-latest
+ # Typical run: ~6s. Read-only package.json diff inspection.
+ timeout-minutes: 5
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-version-check
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ if: github.event_name == 'pull_request'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Check for version changes in package.json
+ env:
+ GITHUB_HEAD_REF: ${{ github.head_ref }}
+ GITHUB_BASE_REF: ${{ github.base_ref }}
+ run: node scripts/check-version.mjs
+
+ # === CHANGESET CHECK - only runs on PRs with code changes ===
+ # Docs-only PRs (./docs folder, markdown files) don't require changesets
+ changeset-check:
+ name: Check for Changesets
+ runs-on: ubuntu-latest
+ # Typical run: <30s including npm install. 10min covers cold runners.
+ timeout-minutes: 10
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-changeset-check
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ needs: [detect-changes]
+ if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24.x'
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Check for changesets
+ env:
+ # Pass PR context to the validation script
+ GITHUB_BASE_REF: ${{ github.base_ref }}
+ GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ GITHUB_HEAD_REF: ${{ github.head_ref }}
+ GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: |
+ # Skip changeset check for automated version PRs
+ if [[ "$GITHUB_HEAD_REF" == "changeset-release/"* ]]; then
+ echo "Skipping changeset check for automated release PR"
+ exit 0
+ fi
+
+ # Run changeset validation script
+ # This validates that exactly ONE changeset was ADDED by this PR
+ # Pre-existing changesets from other merged PRs are ignored
+ node scripts/validate-changeset.mjs
+
+ # === LINT AND FORMAT CHECK ===
+ # Lint runs independently of changeset-check - it's a fast check that should always run
+ # See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed
+ # IMPORTANT: ESLint includes max-lines rule (1500 lines) to ensure files stay maintainable
+ # See docs/case-studies/issue-23 for why fresh merge simulation is critical
+ lint:
+ name: Lint and Format Check
+ runs-on: ubuntu-latest
+ # Typical run: <1min including install, ESLint, Prettier, jscpd,
+ # and secretlint. 10min protects against a hung lint plugin.
+ timeout-minutes: 10
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-lint
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ needs: [detect-changes]
+ if: |
+ !cancelled() &&
+ (
+ github.event_name == 'workflow_dispatch' ||
+ needs.detect-changes.outputs.docs-changed == 'true' ||
+ needs.detect-changes.outputs.any-code-changed == 'true'
+ )
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ # For PRs, fetch enough history to merge with base branch
+ fetch-depth: 0
+
+ - name: Simulate fresh merge with base branch (PR only)
+ if: github.event_name == 'pull_request'
+ env:
+ BASE_REF: ${{ github.base_ref }}
+ run: bash scripts/simulate-fresh-merge.sh
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24.x'
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Run ESLint
+ run: npm run lint
+
+ - name: Check formatting
+ run: npm run format:check
+
+ - name: Check code duplication
+ run: npm run check:duplication
+
+ - name: Check for secrets
+ run: npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint "**/*"
+
+ # Test matrix: 3 runtimes (Node.js, Bun, Deno) x 3 OS (Ubuntu, macOS, Windows)
+ # IMPORTANT: Tests must validate the ACTUAL merge result, not a stale merge preview.
+ # See docs/case-studies/issue-23 for why this is critical.
+ # Fast-fail: slow test matrix only runs after fast checks pass (hive-mind principle #5)
+ test:
+ name: Test (${{ matrix.runtime }} on ${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ # Typical run: <1min per runtime/OS on warm runners, with Windows
+ # sometimes slower on cold starts. Each test step owns an explicit
+ # budget below (see scripts/run-with-budget-warning.sh); this cap is
+ # only the backstop behind those budgets, sized so the budgets always
+ # expire first. A job killed by timeout-minutes reports *cancelled*,
+ # which hides the failure -- see docs/CI-TIMEOUT-BUDGETS.md.
+ timeout-minutes: 15
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-test-${{ matrix.runtime }}-${{ matrix.os }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ needs:
+ [
+ detect-changes,
+ changeset-check,
+ test-compilation,
+ lint,
+ check-file-line-limits,
+ ]
+ # Use !cancelled() instead of always() so cancellation propagates correctly (hive-mind issue #1278)
+ # Run for relevant code/package/workflow changes, or for an instant manual
+ # release. Skipped fast checks count as non-failures only after one of
+ # those positive gates passes.
+ if: |
+ !cancelled() &&
+ (
+ needs.detect-changes.outputs.any-code-changed == 'true' ||
+ (
+ github.event_name == 'workflow_dispatch' &&
+ github.event.inputs.release_mode == 'instant'
+ )
+ ) &&
+ (needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') &&
+ (needs.test-compilation.result == 'success' || needs.test-compilation.result == 'skipped') &&
+ (needs.lint.result == 'success' || needs.lint.result == 'skipped') &&
+ (needs.check-file-line-limits.result == 'success' || needs.check-file-line-limits.result == 'skipped')
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ runtime: [node, bun, deno]
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ # For PRs, fetch enough history to merge with base branch
+ fetch-depth: 0
+
+ - name: Simulate fresh merge with base branch (PR only)
+ if: github.event_name == 'pull_request'
+ env:
+ BASE_REF: ${{ github.base_ref }}
+ shell: bash
+ run: bash scripts/simulate-fresh-merge.sh
+
+ - name: Setup Node.js
+ if: matrix.runtime == 'node'
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24.x'
+
+ - name: Install dependencies (Node.js)
+ if: matrix.runtime == 'node'
+ run: npm install
+
+ - name: Run tests (Node.js)
+ if: matrix.runtime == 'node'
+ shell: bash
+ run: bash scripts/run-with-budget-warning.sh 300 "Node.js test suite" npm test
+
+ - name: Setup Bun
+ if: matrix.runtime == 'bun'
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ bun-version: latest
+
+ - name: Install dependencies (Bun)
+ if: matrix.runtime == 'bun'
+ run: bun install
+
+ - name: Run tests (Bun)
+ if: matrix.runtime == 'bun'
+ shell: bash
+ # --timeout caps an *individual* test at 30s, matching Node's
+ # --test-timeout budget while leaving headroom for cold runners.
+ # It is a per-test bound and does not bound the suite: 25 tests of
+ # 29s each pass every per-test check. The suite budget below is
+ # what bounds the whole run.
+ run: bash scripts/run-with-budget-warning.sh 200 "Bun test suite" bun test --timeout 30000
+
+ - name: Setup Deno
+ if: matrix.runtime == 'deno'
+ uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5
+ with:
+ deno-version: v2.x
+
+ - name: Run tests (Deno)
+ if: matrix.runtime == 'deno'
+ shell: bash
+ run: bash scripts/run-with-budget-warning.sh 100 "Deno test suite" deno test --allow-read
+
+ # === DOCKER IMAGE BUILD CHECK (pull requests) ===
+ # Builds the Dockerfile without pushing so a broken image fails the pull
+ # request instead of surfacing only after publish (issue #106).
+ # push: false + load: true keeps this working for fork pull requests,
+ # which have no registry credentials. Skipped when no Dockerfile exists.
+ docker-build:
+ name: Docker Image Build Check
+ runs-on: ubuntu-latest
+ # Typical run: a few minutes with a warm GHA layer cache. 30min covers
+ # a cold, uncached build without allowing a 6h hang.
+ timeout-minutes: 30
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-docker-build
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ needs: [detect-changes]
+ if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true'
+ permissions:
+ contents: read
+ env:
+ DOCKER_CONTEXT: ${{ vars.DOCKER_CONTEXT }}
+ DOCKERFILE: ${{ vars.DOCKERFILE }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Check Docker build configuration
+ id: docker_config
+ run: node scripts/check-docker-build.mjs
+
+ - name: Set up Docker Buildx (resilient)
+ if: steps.docker_config.outputs.enabled == 'true'
+ uses: ./.github/actions/setup-buildx-resilient
+
+ - name: Build Docker image (no push)
+ if: steps.docker_config.outputs.enabled == 'true'
+ # A `uses:` step cannot be wrapped by run-with-budget-warning.sh, so
+ # it declares its deadline with a step-level timeout-minutes: an
+ # exhausted step budget fails the step, while an exhausted job cap
+ # only cancels the job.
+ timeout-minutes: 20
+ uses: docker/build-push-action@v7
+ with:
+ context: ${{ steps.docker_config.outputs.context }}
+ file: ${{ steps.docker_config.outputs.dockerfile }}
+ push: false
+ load: true
+ tags: pr-check:${{ github.sha }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ # === DOCUMENTATION VALIDATION ===
+ # Validate documentation files when docs change (hive-mind principle #12)
+ validate-docs:
+ name: Validate Documentation
+ runs-on: ubuntu-latest
+ # Typical run: <10s. Pure shell checks over documentation files.
+ timeout-minutes: 5
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-validate-docs
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ needs: [detect-changes]
+ if: |
+ needs.detect-changes.outputs.docs-changed == 'true'
+ steps:
+ - uses: actions/checkout@v6
+
+ # Documentation line limits (1500 lines, matching the architecture
+ # limit) are enforced by the check-file-line-limits job, which scans
+ # every .md file. This job only validates required-file presence.
+ - name: Check required documentation files exist
+ run: |
+ REQUIRED_FILES=(
+ "docs/BEST-PRACTICES.md"
+ "docs/CONTRIBUTING.md"
+ "README.md"
+ "CHANGELOG.md"
+ )
+
+ MISSING=()
+ for file in "${REQUIRED_FILES[@]}"; do
+ if [ ! -f "$file" ]; then
+ echo "ERROR: Required documentation file missing: $file"
+ MISSING+=("$file")
+ else
+ echo "Found: $file"
+ fi
+ done
+
+ if [ "${#MISSING[@]}" -gt 0 ]; then
+ echo ""
+ echo "Missing required documentation files:"
+ printf ' %s\n' "${MISSING[@]}"
+ exit 1
+ else
+ echo "All required documentation files present."
+ fi
+
+ # Release - only runs on main after tests pass (for push events)
+ release:
+ name: Release
+ needs: [lint, test]
+ # Typical run is well under 10min. 30min gives npm and GitHub
+ # release APIs room for retries without allowing a 6h hang.
+ timeout-minutes: 30
+ concurrency:
+ group: main-writer-${{ github.repository }}-main
+ cancel-in-progress: false
+ # Use !cancelled() instead of always() so cancellation propagates correctly (hive-mind issue #1278)
+ # This is needed because lint/test jobs have a transitive dependency on changeset-check
+ if: |
+ !cancelled() &&
+ github.ref == 'refs/heads/main' &&
+ github.event_name == 'push' &&
+ needs.lint.result == 'success' &&
+ needs.test.result == 'success'
+ runs-on: ubuntu-latest
+ # Permissions required for npm OIDC trusted publishing
+ permissions:
+ contents: write
+ pull-requests: write
+ id-token: write
+ outputs:
+ published: ${{ steps.publish.outputs.published }}
+ published_version: ${{ steps.publish.outputs.published_version }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24.x'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Remove deprecated npm auth config
+ run: node scripts/sanitize-npm-userconfig.mjs
+
+ - name: Install dependencies
+ run: bash scripts/run-with-budget-warning.sh 240 "Release dependency install" npm install
+
+ - name: Update npm for OIDC trusted publishing
+ run: node scripts/setup-npm.mjs
+
+ - name: Check for changesets
+ id: check_changesets
+ run: node scripts/check-changesets.mjs
+
+ - name: Check if release is needed
+ id: check_release
+ env:
+ HAS_CHANGESETS: ${{ steps.check_changesets.outputs.has_changesets }}
+ run: node scripts/check-release-needed.mjs
+
+ - name: Merge multiple changesets
+ if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1
+ run: |
+ echo "Multiple changesets detected, merging..."
+ node scripts/merge-changesets.mjs
+
+ - name: Version packages and commit to main
+ if: steps.check_changesets.outputs.has_changesets == 'true'
+ id: version
+ env:
+ # Lets the push helper land the version commit through a pull request
+ # when a repository ruleset declines the direct push (issue #143).
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: node scripts/version-and-commit.mjs --mode changeset
+
+ - name: Publish to npm
+ # Run if version was committed, if a previous attempt already committed (for re-runs),
+ # or if check-release-needed detected an unpublished version (self-healing, issue #36)
+ if: >-
+ steps.version.outputs.version_committed == 'true' ||
+ steps.version.outputs.already_released == 'true' ||
+ (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true')
+ id: publish
+ # Optional NPM_TOKEN bootstrap fallback. OIDC trusted publishing is the
+ # steady-state mechanism, but it cannot create a brand-new package (the
+ # first publish returns E404 because a trusted publisher can only be
+ # configured for a package that already exists). When NPM_TOKEN is set,
+ # the first publish succeeds; once the package exists and a trusted
+ # publisher is configured, OIDC takes over and the token can be removed.
+ # See issue #77.
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ run: bash scripts/run-with-budget-warning.sh 420 "npm publish" node scripts/publish-to-npm.mjs --should-pull
+
+ - name: Smoke-test published npm package
+ if: steps.publish.outputs.published == 'true'
+ run: bash scripts/run-with-budget-warning.sh 300 "npm package smoke test" node scripts/smoke-test-package.mjs --package-version "${{ steps.publish.outputs.published_version }}"
+
+ - name: Create GitHub Release
+ if: steps.publish.outputs.published == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}"
+
+ - name: Format GitHub release notes
+ if: steps.publish.outputs.published == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}"
+
+ # Manual Instant Release - triggered via workflow_dispatch with instant mode
+ # This job is in release.yml because npm trusted publishing
+ # only allows one workflow file to be registered as a trusted publisher
+ instant-release:
+ name: Instant Release
+ # Publishing must wait for both quality gates and require explicit success.
+ needs: [lint, test]
+ if: |
+ !cancelled() &&
+ github.event_name == 'workflow_dispatch' &&
+ github.event.inputs.release_mode == 'instant' &&
+ needs.lint.result == 'success' &&
+ needs.test.result == 'success'
+ runs-on: ubuntu-latest
+ # Same publish envelope as the automated release path.
+ timeout-minutes: 30
+ concurrency:
+ group: main-writer-${{ github.repository }}-main
+ cancel-in-progress: false
+ # Permissions required for npm OIDC trusted publishing
+ permissions:
+ contents: write
+ pull-requests: write
+ id-token: write
+ outputs:
+ published: ${{ steps.publish.outputs.published }}
+ published_version: ${{ steps.publish.outputs.published_version }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24.x'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Remove deprecated npm auth config
+ run: node scripts/sanitize-npm-userconfig.mjs
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Update npm for OIDC trusted publishing
+ run: node scripts/setup-npm.mjs
+
+ - name: Version packages and commit to main
+ id: version
+ env:
+ BUMP_TYPE: ${{ github.event.inputs.bump_type }}
+ RELEASE_DESCRIPTION: ${{ github.event.inputs.description }}
+ # See the release job above (issue #143).
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: node scripts/version-and-commit.mjs --mode instant --bump-type "$BUMP_TYPE" --description "$RELEASE_DESCRIPTION"
+
+ - name: Publish to npm
+ # Run if version was committed OR if a previous attempt already committed (for re-runs)
+ if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
+ id: publish
+ # Optional NPM_TOKEN bootstrap fallback; OIDC trusted publishing is used
+ # when the secret is unset. See the release job above for details (#77).
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ run: node scripts/publish-to-npm.mjs
+
+ - name: Smoke-test published npm package
+ if: steps.publish.outputs.published == 'true'
+ run: node scripts/smoke-test-package.mjs --package-version "${{ steps.publish.outputs.published_version }}"
+
+ - name: Create GitHub Release
+ if: steps.publish.outputs.published == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}"
+
+ - name: Format GitHub release notes
+ if: steps.publish.outputs.published == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}"
+
+ # Optional Docker Hub publishing for packages that also ship Docker images.
+ # Set vars.DOCKERHUB_IMAGE to enable this path, then configure
+ # vars.DOCKERHUB_USERNAME and secrets.DOCKERHUB_TOKEN.
+ docker-publish-config:
+ name: Configure Docker Hub Publish
+ needs: [release, instant-release]
+ timeout-minutes: 10
+ concurrency:
+ group: main-writer-${{ github.repository }}-main
+ cancel-in-progress: false
+ if: |
+ !cancelled() &&
+ (
+ (needs.release.result == 'success' && needs.release.outputs.published == 'true') ||
+ (needs.instant-release.result == 'success' && needs.instant-release.outputs.published == 'true')
+ )
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ context: ${{ steps.docker_config.outputs.context }}
+ dockerfile: ${{ steps.docker_config.outputs.dockerfile }}
+ enabled: ${{ steps.docker_config.outputs.enabled }}
+ image: ${{ steps.docker_config.outputs.image }}
+ version: ${{ steps.release_version.outputs.version }}
+ env:
+ DOCKER_CONTEXT: ${{ vars.DOCKER_CONTEXT }}
+ DOCKERFILE: ${{ vars.DOCKERFILE }}
+ DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }}
+ DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+ DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }}
+ RELEASE_VERSION: ${{ needs.release.outputs.published_version || needs.instant-release.outputs.published_version }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Check Docker publish configuration
+ id: docker_config
+ run: node scripts/check-docker-publish.mjs
+
+ - name: Export release version
+ id: release_version
+ env:
+ VERSION: ${{ env.RELEASE_VERSION }}
+ run: echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+
+ - name: Wait for npm package availability before Docker publish
+ if: steps.docker_config.outputs.enabled == 'true'
+ env:
+ VERSION: ${{ env.RELEASE_VERSION }}
+ run: node scripts/wait-for-npm.mjs --release-version "${VERSION}"
+
+ # Build each architecture on a native runner. Each build is pushed by digest;
+ # docker-publish combines those immutable digests into the release tags.
+ docker-publish-build:
+ name: Build Docker Image (${{ matrix.platform }})
+ needs: [docker-publish-config]
+ if: needs.docker-publish-config.outputs.enabled == 'true'
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: linux/amd64
+ runner: ubuntu-latest
+ - platform: linux/arm64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Build and push platform image by digest
+ id: build
+ # Step-level budget below the job's timeout-minutes backstop; see
+ # docs/CI-TIMEOUT-BUDGETS.md.
+ timeout-minutes: 20
+ uses: ./.github/actions/publish-dockerhub
+ with:
+ context: ${{ needs.docker-publish-config.outputs.context }}
+ file: ${{ needs.docker-publish-config.outputs.dockerfile }}
+ image: ${{ needs.docker-publish-config.outputs.image }}
+ platform: ${{ matrix.platform }}
+ token: ${{ secrets.DOCKERHUB_TOKEN }}
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ version: ${{ needs.docker-publish-config.outputs.version }}
+
+ - name: Export image digest
+ env:
+ DIGEST: ${{ steps.build.outputs.digest }}
+ run: |
+ mkdir -p /tmp/digests
+ touch "/tmp/digests/${DIGEST#sha256:}"
+
+ - name: Upload image digest
+ uses: actions/upload-artifact@v7
+ with:
+ name: docker-digest-${{ strategy.job-index }}
+ path: /tmp/digests/*
+ if-no-files-found: error
+ retention-days: 1
+
+ docker-publish:
+ name: Optional Docker Hub Publish
+ needs: [docker-publish-config, docker-publish-build]
+ if: needs.docker-publish-build.result == 'success'
+ timeout-minutes: 30
+ concurrency:
+ group: main-writer-${{ github.repository }}-main
+ cancel-in-progress: false
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Docker Buildx (resilient)
+ uses: ./.github/actions/setup-buildx-resilient
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v4
+ with:
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Download image digests
+ uses: actions/download-artifact@v8
+ env:
+ NODE_OPTIONS: --disable-warning=DEP0005
+ with:
+ path: /tmp/digests
+ pattern: docker-digest-*
+ merge-multiple: true
+
+ - name: Create multi-architecture manifest
+ working-directory: /tmp/digests
+ env:
+ IMAGE: ${{ needs.docker-publish-config.outputs.image }}
+ VERSION: ${{ needs.docker-publish-config.outputs.version }}
+ run: |
+ # One argument per digest. An array keeps the expansion explicit
+ # instead of relying on word splitting of a command substitution.
+ mapfile -t digests < <(printf "${IMAGE}@sha256:%s\n" *)
+ docker buildx imagetools create \
+ --tag "${IMAGE}:latest" \
+ --tag "${IMAGE}:${VERSION}" \
+ "${digests[@]}"
+
+ # Manual Changeset PR - creates a pull request with the changeset for review
+ changeset-pr:
+ name: Create Changeset PR
+ # PR creation does not publish, but it must still pass the fast lint gate.
+ needs: [lint]
+ if: |
+ !cancelled() &&
+ github.event_name == 'workflow_dispatch' &&
+ github.event.inputs.release_mode == 'changeset-pr' &&
+ needs.lint.result == 'success'
+ runs-on: ubuntu-latest
+ # PR creation only: install, create a changeset, format, and open a PR.
+ timeout-minutes: 10
+ concurrency:
+ group: main-writer-${{ github.repository }}-main
+ cancel-in-progress: false
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24.x'
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Create changeset file
+ env:
+ BUMP_TYPE: ${{ github.event.inputs.bump_type }}
+ RELEASE_DESCRIPTION: ${{ github.event.inputs.description }}
+ run: node scripts/create-manual-changeset.mjs --bump-type "$BUMP_TYPE" --description "$RELEASE_DESCRIPTION"
+
+ - name: Format changeset with Prettier
+ run: |
+ # Run Prettier on the changeset file to ensure it matches project style
+ npx prettier --write ".changeset/*.md" || true
+
+ echo "Formatted changeset files"
+
+ - name: Create Pull Request
+ uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release'
+ branch: changeset-manual-release-${{ github.run_id }}
+ delete-branch: true
+ title: 'chore: manual ${{ github.event.inputs.bump_type }} release'
+ body: |
+ ## Manual Release Request
+
+ This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release.
+
+ ### Release Details
+ - **Type:** ${{ github.event.inputs.bump_type }}
+ - **Description:** ${{ github.event.inputs.description || 'Manual release' }}
+ - **Triggered by:** @${{ github.actor }}
+
+ ### Next Steps
+ 1. Review the changeset in this PR
+ 2. Merge this PR to main
+ 3. The automated release workflow will create a version PR
+ 4. Merge the version PR to publish to npm and create a GitHub release
+
+ # GitHub reports jobs killed by timeout-minutes as cancelled rather than
+ # failed. Observe every job so those cancellations become visible failures
+ # on main, where concurrency never supersedes an in-progress run.
+ pipeline-status:
+ name: Pipeline Status
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-pipeline-status
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ if: always()
+ needs:
+ - detect-changes
+ - test-compilation
+ - check-file-line-limits
+ - version-check
+ - changeset-check
+ - lint
+ - test
+ - docker-build
+ - docker-publish-config
+ - docker-publish-build
+ - validate-docs
+ - release
+ - instant-release
+ - docker-publish
+ - changeset-pr
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24.x'
+
+ - name: Fail the run when a required job was cancelled or failed
+ env:
+ NEEDS_JSON: ${{ toJSON(needs) }}
+ IS_MAIN: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
+ run: bash scripts/check-pipeline-status.sh
diff --git a/dev/log/issues/298/pulls/299/templates/js/security.yml b/dev/log/issues/298/pulls/299/templates/js/security.yml
new file mode 100644
index 00000000..6c0e4219
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/js/security.yml
@@ -0,0 +1,93 @@
+name: Security
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+ schedule:
+ - cron: '0 6 * * 1'
+ workflow_dispatch:
+
+# Least-privilege default; jobs escalate individually when needed.
+permissions:
+ contents: read
+
+# Provide Git config to actions/checkout itself; checkout runs git init before
+# any workflow step can configure Git.
+env:
+ GIT_CONFIG_COUNT: '1'
+ GIT_CONFIG_KEY_0: init.defaultBranch
+ GIT_CONFIG_VALUE_0: main
+
+jobs:
+ codeql:
+ name: CodeQL (${{ matrix.language }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }}
+ cancel-in-progress: true
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [javascript-typescript, actions]
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v4
+
+ - name: Analyze
+ uses: github/codeql-action/analyze@v4
+
+ dependency-review:
+ name: Dependency Review
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-dependency-review
+ cancel-in-progress: true
+ permissions:
+ contents: read
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Review dependency changes
+ uses: actions/dependency-review-action@v5
+ with:
+ fail-on-severity: high
+ comment-summary-in-pr: on-failure
+
+ npm-audit:
+ name: Audit npm lock (${{ matrix.directory }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-npm-audit-${{ matrix.directory }}
+ cancel-in-progress: true
+ strategy:
+ fail-fast: false
+ matrix:
+ directory: ['.', examples/universal-app]
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 24
+
+ - name: Audit current lock
+ working-directory: ${{ matrix.directory }}
+ run: npm audit --package-lock-only --audit-level=high
diff --git a/dev/log/issues/298/pulls/299/templates/js/workflows.yml b/dev/log/issues/298/pulls/299/templates/js/workflows.yml
new file mode 100644
index 00000000..2a4ae4e3
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/js/workflows.yml
@@ -0,0 +1,69 @@
+name: Workflows
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - '.github/workflows/**'
+ - '.github/zizmor.yml'
+ pull_request:
+ types: [opened, synchronize, reopened]
+ paths:
+ - '.github/workflows/**'
+ - '.github/zizmor.yml'
+ workflow_dispatch:
+
+# Least-privilege default; these jobs only need to read the checked-out tree.
+permissions:
+ contents: read
+
+# Provide Git config to actions/checkout itself; checkout runs git init before
+# any workflow step can configure Git.
+env:
+ GIT_CONFIG_COUNT: '1'
+ GIT_CONFIG_KEY_0: init.defaultBranch
+ GIT_CONFIG_VALUE_0: main
+
+jobs:
+ actionlint:
+ name: actionlint
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-actionlint
+ cancel-in-progress: true
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ # The Docker image bundles shellcheck and pyflakes, so this also lints
+ # every `run:` block. A native actionlint binary without shellcheck on
+ # PATH silently skips the shell checks and exits 0 - worth knowing when
+ # reproducing a failure locally:
+ # docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.7 -color
+ - uses: docker://rhysd/actionlint:1.7.7
+ with:
+ args: -color
+
+ zizmor:
+ name: zizmor
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-zizmor
+ cancel-in-progress: true
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ # Annotations instead of SARIF: forks of this template do not necessarily
+ # have code scanning enabled, and the job should fail loudly either way.
+ - uses: zizmorcore/zizmor-action@v0.6.2
+ with:
+ advanced-security: false
+ annotations: true
+ config: .github/zizmor.yml
+ min-confidence: medium
diff --git a/dev/log/issues/298/pulls/299/templates/php-workflows.txt b/dev/log/issues/298/pulls/299/templates/php-workflows.txt
new file mode 100644
index 00000000..9824c1ec
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/php-workflows.txt
@@ -0,0 +1,3 @@
+docs.yml
+links.yml
+release.yml
diff --git a/dev/log/issues/298/pulls/299/templates/php/docs.yml b/dev/log/issues/298/pulls/299/templates/php/docs.yml
new file mode 100644
index 00000000..728198cf
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/php/docs.yml
@@ -0,0 +1,84 @@
+name: Docs
+
+# Builds API documentation with phpDocumentor on every push/PR that touches
+# src/, docs/ or this workflow. On pushes to main the rendered site is
+# published to GitHub Pages; PRs only build to verify the docs still compile.
+#
+# One-time setup: in Settings -> Pages, set Source to "GitHub Actions".
+# Without it the first deploy fails on actions/deploy-pages with
+# "Get Pages site failed". This cannot be configured from a workflow.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'docs/**'
+ - 'src/**'
+ - 'composer.json'
+ - '.github/workflows/docs.yml'
+ pull_request:
+ types: [opened, synchronize, reopened]
+ paths:
+ - 'docs/**'
+ - 'src/**'
+ - 'composer.json'
+ - '.github/workflows/docs.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+jobs:
+ build:
+ name: Build docs
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ coverage: none
+ tools: phpDocumentor
+
+ - name: Build API documentation
+ run: phpDocumentor --directory=src --target=_site --title="$(php -r "echo json_decode(file_get_contents('composer.json'))->name;")"
+
+ - name: Upload build artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: docs-site
+ path: _site
+ if-no-files-found: error
+
+ - name: Configure GitHub Pages
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ uses: actions/configure-pages@v5
+
+ - name: Upload GitHub Pages artifact
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+
+ deploy:
+ name: Deploy to GitHub Pages
+ needs: [build]
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy Pages artifact
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/dev/log/issues/298/pulls/299/templates/php/links.yml b/dev/log/issues/298/pulls/299/templates/php/links.yml
new file mode 100644
index 00000000..d62a9289
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/php/links.yml
@@ -0,0 +1,78 @@
+name: Broken Link Checker
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - '**.md'
+ - '**.html'
+ - '.github/workflows/links.yml'
+ pull_request:
+ types: [opened, synchronize, reopened]
+ paths:
+ - '**.md'
+ - '**.html'
+ - '.github/workflows/links.yml'
+ workflow_dispatch:
+
+jobs:
+ link-checker:
+ name: Check Links
+ runs-on: ubuntu-latest
+ # Typical run: <1min with the lychee cache. 10min prevents slow external
+ # hosts or Wayback Machine probes from hanging the workflow.
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ coverage: none
+
+ - name: Check links with lychee
+ id: lychee
+ uses: lycheeverse/lychee-action@v2
+ with:
+ # The case-studies directory holds research copied from sibling repos
+ # whose relative links resolve there, not here; exclude it.
+ args: >-
+ --verbose
+ --no-progress
+ --cache
+ --max-cache-age 1d
+ --max-retries 3
+ --timeout 30
+ --exclude-path docs/case-studies
+ './**/*.md'
+ './**/*.html'
+ fail: false
+ output: lychee/out.md
+ jobSummary: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Check broken links against the Web Archive
+ if: steps.lychee.outputs.exit_code != 0
+ id: webarchive
+ env:
+ LYCHEE_OUTPUT: lychee/out.md
+ run: php scripts/check-web-archive.php
+
+ - name: Fail if broken links found and no web archive fallback
+ if: steps.lychee.outputs.exit_code != 0 && steps.webarchive.outputs.all_archived != 'true'
+ run: |
+ echo "::error::Broken links were detected with no Web Archive fallback available."
+ echo ""
+ echo "How to fix:"
+ echo " 1. Review the 'Check links with lychee' step above for the full list."
+ echo " 2. For links with a '::notice::' annotation, a Web Archive copy exists —"
+ echo " replace the broken link with the suggested archive.org URL."
+ echo " 3. Otherwise update the URL, remove the link, or add it to .lycheeignore."
+ echo ""
+ echo "Report location: lychee/out.md"
+ exit 1
diff --git a/dev/log/issues/298/pulls/299/templates/php/release.yml b/dev/log/issues/298/pulls/299/templates/php/release.yml
new file mode 100644
index 00000000..94fb10b7
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/php/release.yml
@@ -0,0 +1,332 @@
+name: CI/CD Pipeline
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ types: [opened, synchronize, reopened]
+ workflow_dispatch:
+ inputs:
+ bump_type:
+ description: 'Version bump type'
+ required: true
+ type: choice
+ options:
+ - patch
+ - minor
+ - major
+ description:
+ description: 'Release description (optional)'
+ required: false
+ type: string
+
+# Never cancel an in-progress run on main (a release may be mid-flight); do
+# cancel superseded PR runs.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+permissions:
+ contents: read
+
+env:
+ PHP_VERSION: '8.3'
+
+jobs:
+ # === DETECT CHANGES - determines which downstream jobs need to run ===
+ detect-changes:
+ name: Detect Changes
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ if: github.event_name != 'workflow_dispatch'
+ outputs:
+ php-changed: ${{ steps.changes.outputs.php-changed }}
+ tests-changed: ${{ steps.changes.outputs.tests-changed }}
+ package-changed: ${{ steps.changes.outputs.package-changed }}
+ docs-changed: ${{ steps.changes.outputs.docs-changed }}
+ workflow-changed: ${{ steps.changes.outputs.workflow-changed }}
+ any-code-changed: ${{ steps.changes.outputs.any-code-changed }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ coverage: none
+
+ - name: Detect changes
+ id: changes
+ env:
+ GITHUB_EVENT_NAME: ${{ github.event_name }}
+ GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: php scripts/detect-code-changes.php
+
+ # === LINT AND STATIC ANALYSIS ===
+ # Independent of the changeset check so a fast quality gate always runs.
+ lint:
+ name: Lint and Static Analysis
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ needs: [detect-changes]
+ # always() && !cancelled() lets this evaluate even though detect-changes is
+ # skipped for workflow_dispatch; otherwise the skip would propagate here.
+ if: |
+ always() && !cancelled() && (
+ github.event_name == 'push' ||
+ github.event_name == 'workflow_dispatch' ||
+ needs.detect-changes.outputs.php-changed == 'true' ||
+ needs.detect-changes.outputs.tests-changed == 'true' ||
+ needs.detect-changes.outputs.docs-changed == 'true' ||
+ needs.detect-changes.outputs.package-changed == 'true' ||
+ needs.detect-changes.outputs.workflow-changed == 'true'
+ )
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ coverage: none
+ tools: composer:v2
+
+ - name: Validate composer.json
+ # --no-check-version: the version field is intentionally present and
+ # owned by the release pipeline, which is at odds with Packagist's
+ # "omit version" recommendation; keep strict checks for everything else.
+ run: composer validate --strict --no-check-version
+
+ - name: Install dependencies
+ uses: ramsey/composer-install@v3
+
+ - name: Check coding standards (PHP-CS-Fixer)
+ run: composer lint
+
+ - name: Static analysis (PHPStan)
+ run: composer analyse
+
+ - name: Check file size limit
+ run: composer check:file-size
+
+ # === TEST ===
+ test:
+ name: Test (PHP ${{ matrix.php }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ needs: [detect-changes]
+ if: |
+ always() && !cancelled() && (
+ github.event_name == 'push' ||
+ github.event_name == 'workflow_dispatch' ||
+ needs.detect-changes.outputs.php-changed == 'true' ||
+ needs.detect-changes.outputs.tests-changed == 'true' ||
+ needs.detect-changes.outputs.package-changed == 'true' ||
+ needs.detect-changes.outputs.workflow-changed == 'true'
+ )
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.1', '8.2', '8.3', '8.4']
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: xdebug
+ tools: composer:v2
+
+ - name: Install dependencies
+ uses: ramsey/composer-install@v3
+
+ - name: Run tests
+ run: php vendor/bin/phpunit --coverage-text
+
+ # === CHANGESET CHECK - only on PRs that touch code ===
+ # Docs-only PRs are exempt (no changelog fragment required).
+ changeset:
+ name: Changeset Check
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ needs: [detect-changes]
+ if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true'
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ coverage: none
+ tools: composer:v2
+
+ - name: Install dependencies
+ uses: ramsey/composer-install@v3
+
+ - name: Ensure the version was not bumped by hand
+ env:
+ GITHUB_BASE_REF: ${{ github.base_ref }}
+ run: php scripts/check-version-modification.php
+
+ - name: Validate changelog fragment
+ env:
+ GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: php scripts/validate-changeset.php
+
+ # === BUILD ===
+ # Verifies the package installs cleanly for consumers; runs if lint+test
+ # succeeded or were skipped (docs-only PR).
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ needs: [detect-changes, lint, test]
+ if: |
+ always() && (
+ github.event_name == 'push' ||
+ github.event_name == 'workflow_dispatch' ||
+ (
+ (needs.lint.result == 'success' || needs.lint.result == 'skipped') &&
+ (needs.test.result == 'success' || needs.test.result == 'skipped')
+ )
+ )
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ coverage: none
+ tools: composer:v2
+
+ - name: Validate composer.json
+ # --no-check-version: the version field is intentionally present and
+ # owned by the release pipeline, which is at odds with Packagist's
+ # "omit version" recommendation; keep strict checks for everything else.
+ run: composer validate --strict --no-check-version
+
+ - name: Install dependencies (no dev, as a consumer would)
+ run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
+
+ - name: Verify autoloading works
+ run: php -r "require 'vendor/autoload.php'; echo (new LinkFoundation\Template\Calculator())->add(2, 3), PHP_EOL;"
+
+ # === AUTO RELEASE - push to main ===
+ # Idempotent and self-healing: Packagist + GitHub Releases are the source of
+ # truth, not git tags. Re-running after a partial failure resumes safely.
+ auto-release:
+ name: Auto Release
+ needs: [lint, test, build]
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ coverage: none
+ tools: composer:v2
+
+ - name: Install dependencies
+ uses: ramsey/composer-install@v3
+
+ - name: Decide whether a release is needed
+ id: decide
+ env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: php scripts/check-release-needed.php
+
+ - name: Version, changelog, commit and tag
+ id: version
+ if: steps.decide.outputs.should_release == 'true'
+ run: |
+ if [ "${{ steps.decide.outputs.skip_bump }}" = "true" ]; then
+ php scripts/version-and-commit.php --skip-bump
+ else
+ php scripts/version-and-commit.php --mode=changeset
+ fi
+
+ - name: Wait for Packagist to import the release
+ if: steps.version.outputs.new_version != ''
+ env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ run: php scripts/wait-for-packagist.php --version "${{ steps.version.outputs.new_version }}"
+
+ - name: Create GitHub release
+ if: steps.version.outputs.new_version != ''
+ env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: php scripts/create-github-release.php --version "${{ steps.version.outputs.new_version }}"
+
+ # === MANUAL RELEASE - workflow_dispatch ===
+ manual-release:
+ name: Manual Release
+ needs: [lint, test, build]
+ # always() && !cancelled() prevents the skipped detect-changes dependency
+ # from propagating; explicit result checks gate release behind green CI.
+ if: |
+ always() && !cancelled() &&
+ github.event_name == 'workflow_dispatch' &&
+ needs.lint.result == 'success' &&
+ needs.test.result == 'success' &&
+ needs.build.result == 'success'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ coverage: none
+ tools: composer:v2
+
+ - name: Install dependencies
+ uses: ramsey/composer-install@v3
+
+ - name: Version, changelog, commit and tag
+ id: version
+ run: |
+ php scripts/version-and-commit.php \
+ --mode=instant \
+ --bump="${{ github.event.inputs.bump_type }}" \
+ --description="${{ github.event.inputs.description }}"
+
+ - name: Wait for Packagist to import the release
+ if: steps.version.outputs.new_version != ''
+ env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ run: php scripts/wait-for-packagist.php --version "${{ steps.version.outputs.new_version }}"
+
+ - name: Create GitHub release
+ if: steps.version.outputs.new_version != ''
+ env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: php scripts/create-github-release.php --version "${{ steps.version.outputs.new_version }}"
diff --git a/dev/log/issues/298/pulls/299/templates/python-workflows.txt b/dev/log/issues/298/pulls/299/templates/python-workflows.txt
new file mode 100644
index 00000000..30ba50af
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/python-workflows.txt
@@ -0,0 +1,5 @@
+docs.yml
+links.yml
+release.yml
+security.yml
+workflows.yml
diff --git a/dev/log/issues/298/pulls/299/templates/python/docs.yml b/dev/log/issues/298/pulls/299/templates/python/docs.yml
new file mode 100644
index 00000000..94697c59
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/python/docs.yml
@@ -0,0 +1,95 @@
+name: Docs
+
+# Builds Sphinx documentation on every push and PR that touches docs/, src/, or
+# this workflow. On pushes to main the rendered site is published to GitHub
+# Pages only after deployment is explicitly enabled. PRs only build to verify
+# the docs still compile.
+#
+# One-time setup: in the repository's Settings -> Pages, set Source to
+# "GitHub Actions", then set the DEPLOY_GITHUB_PAGES repository variable to
+# "true". Without the variable, deployment is skipped with a notice. Without
+# Pages configured, actions/deploy-pages fails with "Get Pages site failed".
+# See README.md "Deploying API documentation".
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'docs/**'
+ - 'src/**'
+ - 'pyproject.toml'
+ - '.github/workflows/docs.yml'
+ pull_request:
+ types: [opened, synchronize, reopened]
+ paths:
+ - 'docs/**'
+ - 'src/**'
+ - 'pyproject.toml'
+ - '.github/workflows/docs.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+jobs:
+ build:
+ name: Build docs
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.13'
+
+ - name: Install package and docs dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e .
+ pip install -r docs/requirements.txt
+
+ - name: Build Sphinx documentation
+ run: sphinx-build -W --keep-going -b html docs _site
+
+ - name: Upload build artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: docs-site
+ path: _site
+ if-no-files-found: error
+
+ - name: Report skipped GitHub Pages deployment
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_GITHUB_PAGES != 'true'
+ run: echo "::notice::Skipping GitHub Pages deployment. To enable publishing, set Settings -> Pages -> Source = GitHub Actions, then add repository variable DEPLOY_GITHUB_PAGES=true."
+
+ - name: Configure GitHub Pages
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_GITHUB_PAGES == 'true'
+ uses: actions/configure-pages@v6
+
+ - name: Upload GitHub Pages artifact
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_GITHUB_PAGES == 'true'
+ uses: actions/upload-pages-artifact@v5
+ with:
+ path: _site
+
+ deploy:
+ name: Deploy to GitHub Pages
+ needs: [build]
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_GITHUB_PAGES == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy Pages artifact
+ id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/dev/log/issues/298/pulls/299/templates/python/links.yml b/dev/log/issues/298/pulls/299/templates/python/links.yml
new file mode 100644
index 00000000..4a8387d9
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/python/links.yml
@@ -0,0 +1,82 @@
+name: Broken Link Checker
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - '**.md'
+ - '**.html'
+ - '.github/workflows/links.yml'
+ pull_request:
+ types: [opened, synchronize, reopened]
+ paths:
+ - '**.md'
+ - '**.html'
+ - '.github/workflows/links.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ GIT_CONFIG_COUNT: '1'
+ GIT_CONFIG_KEY_0: init.defaultBranch
+ GIT_CONFIG_VALUE_0: main
+
+jobs:
+ link-checker:
+ name: Check Links
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-link-checker
+ cancel-in-progress: true
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Check links with lychee
+ id: lychee
+ uses: lycheeverse/lychee-action@v2
+ with:
+ args: >-
+ --verbose
+ --no-progress
+ --cache
+ --max-cache-age 1d
+ --max-retries 3
+ --timeout 30
+ --exclude-path docs/case-studies
+ './**/*.md'
+ './**/*.html'
+ fail: false
+ output: lychee/out.md
+ jobSummary: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Check broken links against Web Archive
+ if: steps.lychee.outputs.exit_code != 0
+ id: webarchive
+ run: python scripts/check_web_archive.py
+ env:
+ LYCHEE_OUTPUT: lychee/out.md
+
+ - name: Fail if broken links were found
+ if: always() && steps.lychee.outputs.exit_code != 0
+ run: |
+ echo "::error::Broken live links were detected."
+ echo ""
+ echo "What happened:"
+ echo " lychee found one or more broken links in the *.md and *.html files of this repository."
+ echo " An available Web Archive snapshot is a suggested replacement; it does not make the live link valid."
+ echo ""
+ echo "How to fix:"
+ echo " 1. Review the 'Check links with lychee' step above for the broken links."
+ echo " 2. Replace links with suggested archive.org URLs when available."
+ echo " 3. Otherwise find an updated URL, remove the link, or add a known false positive to .lycheeignore."
+ echo ""
+ echo "Report location: lychee/out.md."
+ exit 1
diff --git a/dev/log/issues/298/pulls/299/templates/python/release.yml b/dev/log/issues/298/pulls/299/templates/python/release.yml
new file mode 100644
index 00000000..210d429a
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/python/release.yml
@@ -0,0 +1,897 @@
+name: CI/CD Pipeline
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ types: [opened, synchronize, reopened]
+ workflow_dispatch:
+ inputs:
+ bump_type:
+ description: 'Version bump type'
+ required: true
+ type: choice
+ options:
+ - patch
+ - minor
+ - major
+ description:
+ description: 'Release description (optional)'
+ required: false
+ type: string
+
+permissions:
+ contents: read
+
+env:
+ GIT_CONFIG_COUNT: '1'
+ GIT_CONFIG_KEY_0: init.defaultBranch
+ GIT_CONFIG_VALUE_0: main
+
+# Concurrency is intentionally job-scoped. Superseded read-only jobs cancel
+# independently away from main. Jobs on main are never superseded, which lets
+# the terminal status gate reliably treat a cancellation there as a timeout or
+# manual cancellation. Write-capable release jobs share one non-cancelling
+# concurrency group: a write that has started is never interrupted, and GitHub
+# holds at most one pending run for the group. GitHub Actions accepts only
+# `group` and `cancel-in-progress` here -- there is no queue-depth key.
+jobs:
+ # === DETECT CHANGES - determines which jobs should run ===
+ detect-changes:
+ name: Detect Changes
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}-detect-changes
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ if: github.event_name != 'workflow_dispatch'
+ outputs:
+ any-code-changed: ${{ steps.changes.outputs.any-code-changed }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.13'
+
+ - name: Detect Python layout
+ id: python_layout
+ run: |
+ if [ -f pyproject.toml ]; then
+ PYTHON_ROOT="."
+ MULTI_LANGUAGE="false"
+ echo "root=." >> "$GITHUB_OUTPUT"
+ echo "multi_language=false" >> "$GITHUB_OUTPUT"
+ elif [ -f python/pyproject.toml ]; then
+ PYTHON_ROOT="python"
+ MULTI_LANGUAGE="true"
+ echo "root=python" >> "$GITHUB_OUTPUT"
+ echo "multi_language=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml"
+ exit 1
+ fi
+
+ if [ "$PYTHON_ROOT" = "." ]; then
+ echo "dist_dir=dist" >> "$GITHUB_OUTPUT"
+ else
+ echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT"
+ fi
+ echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE"
+
+ - name: Detect changes
+ id: changes
+ env:
+ GITHUB_EVENT_NAME: ${{ github.event_name }}
+ GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: python "${{ steps.python_layout.outputs.root }}/scripts/detect_code_changes.py"
+
+ # REQUIRED CI CHECKS - All must pass before release
+ # These jobs ensure code quality and tests pass before any release
+
+ # === LINT AND FORMAT CHECK ===
+ # Lint runs independently of changelog check for detected code changes.
+ # See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed
+ lint:
+ name: Lint and Format Check
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}-lint
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ needs: [detect-changes]
+ # !cancelled() lets this job evaluate even though detect-changes is skipped
+ # for workflow_dispatch while still propagating workflow cancellation.
+ if: |
+ !cancelled() && (
+ github.event_name == 'workflow_dispatch' ||
+ needs.detect-changes.outputs.any-code-changed == 'true'
+ )
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Simulate fresh merge with base branch (PR only)
+ if: github.event_name == 'pull_request'
+ env:
+ BASE_REF: ${{ github.base_ref }}
+ run: bash scripts/simulate-fresh-merge.sh
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.13'
+
+ - name: Detect Python layout
+ id: python_layout
+ run: |
+ if [ -f pyproject.toml ]; then
+ PYTHON_ROOT="."
+ MULTI_LANGUAGE="false"
+ echo "root=." >> "$GITHUB_OUTPUT"
+ echo "multi_language=false" >> "$GITHUB_OUTPUT"
+ elif [ -f python/pyproject.toml ]; then
+ PYTHON_ROOT="python"
+ MULTI_LANGUAGE="true"
+ echo "root=python" >> "$GITHUB_OUTPUT"
+ echo "multi_language=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml"
+ exit 1
+ fi
+
+ if [ "$PYTHON_ROOT" = "." ]; then
+ echo "dist_dir=dist" >> "$GITHUB_OUTPUT"
+ else
+ echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT"
+ fi
+ echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE"
+
+ - name: Install dependencies
+ env:
+ INSTALL_BUDGET_SECONDS: 300
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ bash scripts/run-with-budget-warning.sh \
+ "$INSTALL_BUDGET_SECONDS" "Install dependencies" \
+ bash -euo pipefail -c '
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+ '
+
+ - name: Run Ruff linting
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ ruff check .
+
+ - name: Check Ruff formatting
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ ruff format --check .
+
+ - name: Run mypy
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ mypy src
+
+ - name: Check file size limit
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ python scripts/check_file_size.py
+
+ - name: Check for secrets
+ env:
+ SECRETLINT_BUDGET_SECONDS: 300
+ run: |
+ bash "${{ steps.python_layout.outputs.root }}/scripts/run-with-budget-warning.sh" \
+ "$SECRETLINT_BUDGET_SECONDS" "Check for secrets" \
+ npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint "**/*"
+
+ # === TEST ===
+ # Test on latest Python version only
+ test:
+ name: Test (Python 3.13)
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}-test
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ needs: [detect-changes]
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+ # !cancelled() lets this job evaluate even though detect-changes is skipped
+ # for workflow_dispatch while still propagating workflow cancellation.
+ if: |
+ !cancelled() && (
+ github.event_name == 'workflow_dispatch' ||
+ needs.detect-changes.outputs.any-code-changed == 'true'
+ )
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.13'
+
+ - name: Detect Python layout
+ id: python_layout
+ run: |
+ if [ -f pyproject.toml ]; then
+ PYTHON_ROOT="."
+ MULTI_LANGUAGE="false"
+ echo "root=." >> "$GITHUB_OUTPUT"
+ echo "multi_language=false" >> "$GITHUB_OUTPUT"
+ elif [ -f python/pyproject.toml ]; then
+ PYTHON_ROOT="python"
+ MULTI_LANGUAGE="true"
+ echo "root=python" >> "$GITHUB_OUTPUT"
+ echo "multi_language=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml"
+ exit 1
+ fi
+
+ if [ "$PYTHON_ROOT" = "." ]; then
+ echo "dist_dir=dist" >> "$GITHUB_OUTPUT"
+ else
+ echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT"
+ fi
+ echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE"
+
+ - name: Install dependencies
+ env:
+ INSTALL_BUDGET_SECONDS: 300
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ bash scripts/run-with-budget-warning.sh \
+ "$INSTALL_BUDGET_SECONDS" "Install dependencies" \
+ bash -euo pipefail -c '
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+ '
+
+ - name: Run tests
+ env:
+ TEST_BUDGET_SECONDS: 900
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ bash scripts/run-with-budget-warning.sh \
+ "$TEST_BUDGET_SECONDS" "Run tests" \
+ pytest tests/ -v --cov=src --cov-report=xml --cov-report=term
+
+ - name: Report skipped Codecov upload
+ if: env.CODECOV_TOKEN == ''
+ run: echo "::notice::Skipping Codecov upload because CODECOV_TOKEN is not configured"
+
+ - name: Upload coverage to Codecov
+ if: env.CODECOV_TOKEN != ''
+ uses: codecov/codecov-action@v7
+ with:
+ files: ${{ steps.python_layout.outputs.root }}/coverage.xml
+ token: ${{ env.CODECOV_TOKEN }}
+ disable_search: true
+ fail_ci_if_error: true
+
+ # === BUILD PACKAGE ===
+ # Build package after required checks pass for detected code changes or dispatches.
+ build:
+ name: Build Package
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}-build
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ needs: [detect-changes, lint, test]
+ # Build change-bearing automatic events and all manually dispatched releases.
+ if: |
+ !cancelled() && (
+ github.event_name == 'workflow_dispatch' ||
+ needs.detect-changes.outputs.any-code-changed == 'true'
+ ) && (
+ needs.lint.result == 'success' &&
+ needs.test.result == 'success'
+ )
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.13'
+
+ - name: Detect Python layout
+ id: python_layout
+ run: |
+ if [ -f pyproject.toml ]; then
+ PYTHON_ROOT="."
+ MULTI_LANGUAGE="false"
+ echo "root=." >> "$GITHUB_OUTPUT"
+ echo "multi_language=false" >> "$GITHUB_OUTPUT"
+ elif [ -f python/pyproject.toml ]; then
+ PYTHON_ROOT="python"
+ MULTI_LANGUAGE="true"
+ echo "root=python" >> "$GITHUB_OUTPUT"
+ echo "multi_language=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml"
+ exit 1
+ fi
+
+ if [ "$PYTHON_ROOT" = "." ]; then
+ echo "dist_dir=dist" >> "$GITHUB_OUTPUT"
+ else
+ echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT"
+ fi
+ echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE"
+
+ - name: Install build dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install build twine
+
+ - name: Build package
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ python -m build
+
+ - name: Check package
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ twine check dist/*
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v7
+ with:
+ name: dist
+ path: ${{ steps.python_layout.outputs.dist_dir }}
+
+ # === CHANGELOG CHECK - only runs on PRs with code changes ===
+ # Docs-only PRs (./docs folder, markdown files) don't require changelog fragments
+ changelog:
+ name: Changelog Fragment Check
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}-changelog
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ needs: [detect-changes]
+ if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.13"
+
+ - name: Detect Python layout
+ id: python_layout
+ run: |
+ if [ -f pyproject.toml ]; then
+ PYTHON_ROOT="."
+ MULTI_LANGUAGE="false"
+ echo "root=." >> "$GITHUB_OUTPUT"
+ echo "multi_language=false" >> "$GITHUB_OUTPUT"
+ elif [ -f python/pyproject.toml ]; then
+ PYTHON_ROOT="python"
+ MULTI_LANGUAGE="true"
+ echo "root=python" >> "$GITHUB_OUTPUT"
+ echo "multi_language=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml"
+ exit 1
+ fi
+
+ if [ "$PYTHON_ROOT" = "." ]; then
+ echo "dist_dir=dist" >> "$GITHUB_OUTPUT"
+ else
+ echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT"
+ fi
+ echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE"
+
+ - name: Install scriv
+ run: pip install "scriv[toml]"
+
+ - name: Check for changelog fragments
+ env:
+ GITHUB_BASE_REF: ${{ github.base_ref }}
+ run: |
+ set -euo pipefail
+
+ PYTHON_ROOT="${{ steps.python_layout.outputs.root }}"
+ if [ "$PYTHON_ROOT" = "." ]; then
+ FRAGMENT_DIR="changelog.d"
+ SOURCE_PATTERN="^(src/|tests/|scripts/)"
+ else
+ FRAGMENT_DIR="$PYTHON_ROOT/changelog.d"
+ SOURCE_PATTERN="^$PYTHON_ROOT/(src/|tests/|scripts/)"
+ fi
+
+ # Get list of fragment files (excluding README and template)
+ FRAGMENTS=$(find "$FRAGMENT_DIR" -name "*.md" ! -name "README.md" ! -name "*.j2" 2>/dev/null | wc -l)
+
+ # Get changed files in PR
+ CHANGED_FILES=$(git diff --name-only "origin/${GITHUB_BASE_REF}...HEAD")
+
+ # Check if any source files changed (excluding docs and config)
+ SOURCE_CHANGED=$(printf '%s\n' "$CHANGED_FILES" | grep -cE "$SOURCE_PATTERN" || true)
+
+ if [ "$SOURCE_CHANGED" -gt 0 ] && [ "$FRAGMENTS" -eq 0 ]; then
+ echo "::error::No changelog fragment found. Please run 'scriv create' and document your changes."
+ echo ""
+ echo "To create a changelog fragment:"
+ echo " pip install 'scriv[toml]'"
+ echo " scriv create"
+ echo ""
+ echo "This is similar to adding a changeset in JavaScript projects."
+ echo "See changelog.d/README.md for more information."
+ exit 1
+ fi
+
+ echo "✓ Changelog check passed"
+
+ # === DOCKER IMAGE BUILD CHECK (pull requests) ===
+ # Build without pushing so a broken Dockerfile fails before package publish.
+ # Skip cleanly when a generated repository does not include a Dockerfile.
+ docker-build:
+ name: Docker Image Build Check
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}-docker-build
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+ needs: [detect-changes]
+ if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true'
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Detect Dockerfile
+ id: dockerfile
+ run: |
+ if [ -f Dockerfile ]; then
+ echo "exists=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::Skipping Docker build because no Dockerfile is present"
+ fi
+
+ - name: Set up Docker Buildx
+ if: steps.dockerfile.outputs.exists == 'true'
+ uses: docker/setup-buildx-action@v4
+
+ - name: Build Docker image (no push)
+ if: steps.dockerfile.outputs.exists == 'true'
+ timeout-minutes: 40
+ uses: docker/build-push-action@v7
+ with:
+ context: .
+ push: false
+ load: true
+ tags: app:pr-check
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ # RELEASE JOBS - Only run after all CI checks pass
+
+ # Automatic release on push to main (if version changed)
+ auto-release:
+ name: Auto Release
+ needs: [lint, test, build]
+ outputs:
+ released: ${{ steps.github_release.outputs.released }}
+ version: ${{ steps.github_release.outputs.version }}
+ concurrency:
+ group: ${{ github.workflow }}-main-write
+ cancel-in-progress: false
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: write
+ id-token: write
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.13'
+
+ - name: Detect Python layout
+ id: python_layout
+ run: |
+ if [ -f pyproject.toml ]; then
+ PYTHON_ROOT="."
+ MULTI_LANGUAGE="false"
+ echo "root=." >> "$GITHUB_OUTPUT"
+ echo "multi_language=false" >> "$GITHUB_OUTPUT"
+ elif [ -f python/pyproject.toml ]; then
+ PYTHON_ROOT="python"
+ MULTI_LANGUAGE="true"
+ echo "root=python" >> "$GITHUB_OUTPUT"
+ echo "multi_language=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml"
+ exit 1
+ fi
+
+ if [ "$PYTHON_ROOT" = "." ]; then
+ echo "dist_dir=dist" >> "$GITHUB_OUTPUT"
+ else
+ echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT"
+ fi
+ echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install build twine
+
+ - name: Check if version changed
+ id: version_check
+ run: |
+ PYTHON_ROOT="${{ steps.python_layout.outputs.root }}"
+ PYPROJECT="$PYTHON_ROOT/pyproject.toml"
+
+ # Get current version from pyproject.toml
+ CURRENT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' "$PYPROJECT")
+ echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
+
+ if [ "${{ steps.python_layout.outputs.multi_language }}" = "true" ]; then
+ TAG="py_v$CURRENT_VERSION"
+ else
+ TAG="v$CURRENT_VERSION"
+ fi
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
+
+ # Check if tag exists
+ if git rev-parse "$TAG" >/dev/null 2>&1; then
+ echo "Tag $TAG already exists, skipping release"
+ echo "should_release=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "New version detected: $CURRENT_VERSION ($TAG)"
+ echo "should_release=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Download artifacts
+ if: steps.version_check.outputs.should_release == 'true'
+ uses: actions/download-artifact@v7
+ with:
+ name: dist
+ path: dist/
+
+ - name: Publish to PyPI
+ if: steps.version_check.outputs.should_release == 'true'
+ uses: pypa/gh-action-pypi-publish@release/v1
+
+ - name: Smoke test published package
+ if: steps.version_check.outputs.should_release == 'true'
+ run: |
+ python scripts/smoke_test_published_package.py \
+ --version "${{ steps.version_check.outputs.current_version }}"
+
+ - name: Create GitHub Release
+ id: github_release
+ if: steps.version_check.outputs.should_release == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_VERSION: ${{ steps.version_check.outputs.current_version }}
+ run: |
+ python "${{ steps.python_layout.outputs.root }}/scripts/create_github_release.py" \
+ --version "$RELEASE_VERSION" \
+ --repository "${{ github.repository }}" \
+ --repository-root "."
+ echo "released=true" >> "$GITHUB_OUTPUT"
+ echo "version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
+
+ # Manual release via workflow_dispatch - only after CI passes
+ manual-release:
+ name: Manual Release
+ needs: [lint, test, build]
+ outputs:
+ released: ${{ steps.github_release.outputs.released }}
+ version: ${{ steps.github_release.outputs.version }}
+ concurrency:
+ group: ${{ github.workflow }}-main-write
+ cancel-in-progress: false
+ # !cancelled() prevents the skipped detect-changes dependency from propagating
+ # through lint/test/build while still propagating workflow cancellation.
+ # The explicit result checks ensure release only proceeds after CI passes.
+ if: |
+ !cancelled() &&
+ github.event_name == 'workflow_dispatch' &&
+ needs.lint.result == 'success' &&
+ needs.test.result == 'success' &&
+ needs.build.result == 'success'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: write
+ id-token: write
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.13'
+
+ - name: Detect Python layout
+ id: python_layout
+ run: |
+ if [ -f pyproject.toml ]; then
+ PYTHON_ROOT="."
+ MULTI_LANGUAGE="false"
+ echo "root=." >> "$GITHUB_OUTPUT"
+ echo "multi_language=false" >> "$GITHUB_OUTPUT"
+ elif [ -f python/pyproject.toml ]; then
+ PYTHON_ROOT="python"
+ MULTI_LANGUAGE="true"
+ echo "root=python" >> "$GITHUB_OUTPUT"
+ echo "multi_language=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml"
+ exit 1
+ fi
+
+ if [ "$PYTHON_ROOT" = "." ]; then
+ echo "dist_dir=dist" >> "$GITHUB_OUTPUT"
+ else
+ echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT"
+ fi
+ echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE"
+
+ - name: Install dependencies
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ python -m pip install --upgrade pip
+ pip install build twine "scriv[toml]"
+
+ - name: Configure git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Collect changelog fragments
+ env:
+ BUMP_TYPE: ${{ github.event.inputs.bump_type }}
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ # Check if there are any fragments to collect
+ FRAGMENTS=$(find changelog.d -name "*.md" ! -name "README.md" ! -name "*.j2" 2>/dev/null | wc -l)
+ if [ "$FRAGMENTS" -gt 0 ]; then
+ echo "Found $FRAGMENTS changelog fragment(s), collecting..."
+ scriv collect --version "$BUMP_TYPE"
+ else
+ echo "No changelog fragments found, skipping collection"
+ fi
+
+ - name: Version and commit
+ id: version
+ env:
+ BUMP_TYPE: ${{ github.event.inputs.bump_type }}
+ DESCRIPTION: ${{ github.event.inputs.description }}
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ python scripts/version_and_commit.py \
+ --bump-type "$BUMP_TYPE" \
+ --description "$DESCRIPTION"
+
+ - name: Build package
+ if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ python -m build
+
+ - name: Check package
+ if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
+ run: |
+ cd "${{ steps.python_layout.outputs.root }}"
+ twine check dist/*
+
+ - name: Publish to PyPI
+ if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: ${{ steps.python_layout.outputs.dist_dir }}
+
+ - name: Smoke test published package
+ if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
+ run: |
+ python scripts/smoke_test_published_package.py \
+ --version "${{ steps.version.outputs.new_version }}"
+
+ - name: Create GitHub Release
+ id: github_release
+ if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_VERSION: ${{ steps.version.outputs.new_version }}
+ run: |
+ python "${{ steps.python_layout.outputs.root }}/scripts/create_github_release.py" \
+ --version "$RELEASE_VERSION" \
+ --repository "${{ github.repository }}" \
+ --repository-root "."
+ echo "released=true" >> "$GITHUB_OUTPUT"
+ echo "version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
+
+ # Optional Docker Hub publishing after the GitHub release exists. Set the
+ # DOCKERHUB_IMAGE and DOCKERHUB_USERNAME repository variables and the
+ # DOCKERHUB_TOKEN secret to enable it in repositories that ship a Dockerfile.
+ docker-publish-config:
+ name: Configure Docker Hub Publish
+ needs: [auto-release, manual-release]
+ if: |
+ !cancelled() && (
+ needs.auto-release.outputs.released == 'true' ||
+ needs.manual-release.outputs.released == 'true'
+ )
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ outputs:
+ enabled: ${{ steps.config.outputs.enabled }}
+ image: ${{ steps.config.outputs.image }}
+ version: ${{ steps.config.outputs.version }}
+ env:
+ DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }}
+ DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }}
+ DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+ RELEASE_VERSION: ${{ needs.auto-release.outputs.version || needs.manual-release.outputs.version }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Check Docker publish configuration
+ id: config
+ run: |
+ if [ ! -f Dockerfile ]; then
+ echo "::notice::Skipping Docker publish because no Dockerfile is present"
+ echo "enabled=false" >> "$GITHUB_OUTPUT"
+ elif [ -z "$DOCKERHUB_IMAGE" ] || [ -z "$DOCKERHUB_USERNAME" ] || [ -z "$DOCKERHUB_TOKEN" ]; then
+ echo "::notice::Skipping Docker publish because Docker Hub is not configured"
+ echo "enabled=false" >> "$GITHUB_OUTPUT"
+ else
+ {
+ echo "enabled=true"
+ echo "image=$DOCKERHUB_IMAGE"
+ echo "version=$RELEASE_VERSION"
+ } >> "$GITHUB_OUTPUT"
+ fi
+
+ # Native runners build both architectures in parallel and push immutable
+ # digests. This avoids the performance and reliability cost of QEMU.
+ docker-publish-build:
+ name: Build Docker Image (${{ matrix.platform }})
+ needs: [docker-publish-config]
+ if: needs.docker-publish-config.outputs.enabled == 'true'
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: linux/amd64
+ runner: ubuntu-latest
+ - platform: linux/arm64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v4
+ with:
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Build and push platform image by digest
+ id: docker_build
+ timeout-minutes: 40
+ uses: docker/build-push-action@v7
+ with:
+ context: .
+ platforms: ${{ matrix.platform }}
+ cache-from: type=gha,scope=${{ matrix.platform }}
+ cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+ outputs: type=image,name=${{ needs.docker-publish-config.outputs.image }},push-by-digest=true,name-canonical=true,push=true
+
+ - name: Export image digest
+ env:
+ DIGEST: ${{ steps.docker_build.outputs.digest }}
+ run: |
+ mkdir -p /tmp/digests
+ touch "/tmp/digests/${DIGEST#sha256:}"
+
+ - name: Upload image digest
+ uses: actions/upload-artifact@v7
+ with:
+ name: docker-digest-${{ strategy.job-index }}
+ path: /tmp/digests/*
+ if-no-files-found: error
+ retention-days: 1
+
+ docker-publish:
+ name: Publish Multi-Architecture Docker Image
+ needs: [docker-publish-config, docker-publish-build]
+ if: needs.docker-publish-build.result == 'success'
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ steps:
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v4
+ with:
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Download image digests
+ uses: actions/download-artifact@v7
+ with:
+ path: /tmp/digests
+ pattern: docker-digest-*
+ merge-multiple: true
+
+ - name: Create multi-architecture manifest
+ working-directory: /tmp/digests
+ env:
+ IMAGE: ${{ needs.docker-publish-config.outputs.image }}
+ VERSION: ${{ needs.docker-publish-config.outputs.version }}
+ run: |
+ # Word splitting is deliberate: every digest file in this directory
+ # becomes its own source argument for the manifest.
+ # shellcheck disable=SC2046
+ docker buildx imagetools create \
+ --tag "${IMAGE}:latest" \
+ --tag "${IMAGE}:${VERSION}" \
+ $(printf "${IMAGE}@sha256:%s " *)
+
+ - name: Verify multi-architecture manifest
+ env:
+ IMAGE: ${{ needs.docker-publish-config.outputs.image }}
+ VERSION: ${{ needs.docker-publish-config.outputs.version }}
+ run: |
+ MANIFEST=$(docker buildx imagetools inspect "${IMAGE}:${VERSION}")
+ grep -q "linux/amd64" <<< "$MANIFEST"
+ grep -q "linux/arm64" <<< "$MANIFEST"
+
+ # A job killed by timeout-minutes concludes "cancelled", not "failure".
+ # Observe every other job so that state cannot silently hide a broken run.
+ pipeline-status:
+ name: Pipeline Status
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ if: always()
+ needs: [detect-changes, lint, test, build, changelog, docker-build,
+ auto-release, manual-release, docker-publish-config,
+ docker-publish-build, docker-publish]
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Fail the run when a required job was cancelled or failed
+ env:
+ NEEDS_JSON: ${{ toJSON(needs) }}
+ IS_MAIN: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
+ run: bash scripts/check-pipeline-status.sh
diff --git a/dev/log/issues/298/pulls/299/templates/python/security.yml b/dev/log/issues/298/pulls/299/templates/python/security.yml
new file mode 100644
index 00000000..a252a4a1
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/python/security.yml
@@ -0,0 +1,75 @@
+name: Security
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ schedule:
+ - cron: '0 6 * * 1'
+
+permissions:
+ contents: read
+
+jobs:
+ dependency-audit:
+ name: Audit Resolved Python Dependencies
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-dependency-audit
+ cancel-in-progress: true
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.13'
+
+ - name: Audit pyproject.toml and docs/requirements.txt
+ run: python scripts/audit_dependencies.py
+
+ codeql:
+ name: CodeQL (${{ matrix.language }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }}
+ cancel-in-progress: true
+ permissions:
+ contents: read
+ security-events: write
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [python, actions]
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v4
+
+ - name: Analyze
+ uses: github/codeql-action/analyze@v4
+
+ dependency-review:
+ name: Dependency Review
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Review dependency changes
+ uses: actions/dependency-review-action@v5
+ with:
+ fail-on-severity: high
+ comment-summary-in-pr: on-failure
diff --git a/dev/log/issues/298/pulls/299/templates/python/workflows.yml b/dev/log/issues/298/pulls/299/templates/python/workflows.yml
new file mode 100644
index 00000000..56819a4d
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/python/workflows.yml
@@ -0,0 +1,42 @@
+name: Workflows
+
+# GitHub Actions ignores unknown workflow keys instead of rejecting them, so a
+# typo such as `queue: max` inside a `concurrency:` block silently does nothing
+# (issue #62). actionlint validates the schema and, through shellcheck, every
+# `run:` block as well.
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - '.github/**'
+ pull_request:
+ types: [opened, synchronize, reopened]
+ paths:
+ - '.github/**'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ actionlint:
+ name: Lint Workflows
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ concurrency:
+ group: check-${{ github.workflow }}-${{ github.ref }}-actionlint
+ cancel-in-progress: true
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+
+ # The Docker image bundles shellcheck and pyflakes, so this lints every
+ # `run:` block too. A native actionlint binary without shellcheck on
+ # PATH silently skips the shell checks and still exits 0 -- worth knowing
+ # before trying to reproduce a finding locally.
+ - name: Lint workflow files
+ uses: docker://rhysd/actionlint:1.7.7
+ with:
+ args: -color
diff --git a/dev/log/issues/298/pulls/299/templates/rust-publish-crate.rs b/dev/log/issues/298/pulls/299/templates/rust-publish-crate.rs
new file mode 100644
index 00000000..bb61d1e6
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/rust-publish-crate.rs
@@ -0,0 +1,338 @@
+#!/usr/bin/env rust-script
+//! Publish package to crates.io
+//!
+//! This script publishes the Rust package to crates.io and handles
+//! the case where the version already exists.
+//!
+//! Supports both single-language and multi-language repository structures:
+//! - Single-language: Cargo.toml in repository root
+//! - Multi-language: Cargo.toml in rust/ subfolder
+//!
+//! Usage: rust-script scripts/publish-crate.rs [--token ] [--rust-root ]
+//!
+//! Environment variables (checked in order of priority):
+//! - CARGO_REGISTRY_TOKEN: Cargo's native crates.io token (preferred)
+//! - CARGO_TOKEN: Alternative token name for backwards compatibility
+//!
+//! Outputs (written to GITHUB_OUTPUT):
+//! - publish_result: one of
+//! 'success' - the crate version was published to crates.io
+//! 'already_exists' - the version is already on crates.io (version-bump bug)
+//! 'auth_failed' - missing or invalid crates.io authentication token
+//! 'rate_limited' - crates.io returned HTTP 429 (too many versions in 24h);
+//! deferred, automatically-recoverable outcome — the
+//! script exits 0 and the same version is retried on the
+//! next push to 'main' once the throttle window clears
+//! 'skipped' - publish skipped (e.g. template default package name)
+//! 'failed' - publish failed for an unrecognised reason
+//!
+//! ```cargo
+//! [dependencies]
+//! regex = "1"
+//! ```
+
+use std::env;
+use std::fs;
+use std::io::Write;
+use std::process::{Command, exit};
+
+#[path = "rust-paths.rs"]
+mod rust_paths;
+
+fn get_arg(name: &str) -> Option {
+ let args: Vec = env::args().collect();
+ let flag = format!("--{}", name);
+
+ if let Some(idx) = args.iter().position(|a| a == &flag) {
+ return args.get(idx + 1).cloned();
+ }
+
+ None
+}
+
+fn needs_cd(rust_root: &str) -> bool {
+ rust_root != "."
+}
+
+fn set_output(key: &str, value: &str) {
+ if let Ok(output_file) = env::var("GITHUB_OUTPUT") {
+ if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(&output_file) {
+ let _ = writeln!(file, "{}={}", key, value);
+ }
+ }
+ println!("Output: {}={}", key, value);
+}
+
+/// Classification of a failed `cargo publish` attempt.
+///
+/// Every failure branch funnels through this single enum so the `publish_result`
+/// output value and the catch-all behaviour cannot drift apart over time.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum FailureKind {
+ AlreadyExists,
+ AuthFailed,
+ RateLimited,
+ Unknown,
+}
+
+impl FailureKind {
+ fn output_value(self) -> &'static str {
+ match self {
+ FailureKind::AlreadyExists => "already_exists",
+ FailureKind::AuthFailed => "auth_failed",
+ FailureKind::RateLimited => "rate_limited",
+ FailureKind::Unknown => "failed",
+ }
+ }
+
+ /// Whether this failure is a *deferred*, automatically-recoverable outcome
+ /// rather than a hard error.
+ ///
+ /// A crates.io HTTP 429 throttle is transient: the same version is re-tried
+ /// on the next push to `main` once the 24-hour window clears (see
+ /// `scripts/check-release-needed.rs`). The script therefore exits
+ /// successfully for this case so a recoverable throttle does not turn the
+ /// whole release job red. Every other failure stays non-zero.
+ fn is_deferred(self) -> bool {
+ matches!(self, FailureKind::RateLimited)
+ }
+}
+
+/// Classify a combined stdout/stderr blob from `cargo publish` into a
+/// [`FailureKind`].
+///
+/// Note: the rate-limit checks come before the auth checks because a crates.io
+/// 429 body never contains the auth-token markers, while the rate-limit markers
+/// are unambiguous.
+fn classify_failure(combined: &str) -> FailureKind {
+ if combined.contains("already uploaded") || combined.contains("already exists") {
+ FailureKind::AlreadyExists
+ } else if combined.contains("429 Too Many Requests")
+ || combined.contains("Too Many Requests")
+ || combined.contains("too many versions")
+ || combined.contains("too many requests")
+ {
+ FailureKind::RateLimited
+ } else if combined.contains("non-empty token")
+ || combined.contains("please provide a")
+ || combined.contains("unauthorized")
+ || combined.contains("authentication")
+ {
+ FailureKind::AuthFailed
+ } else {
+ FailureKind::Unknown
+ }
+}
+
+fn main() {
+ let rust_root = match rust_paths::get_rust_root(None, true) {
+ Ok(root) => root,
+ Err(e) => {
+ eprintln!("Error: {}", e);
+ exit(1);
+ }
+ };
+ let cargo_toml = rust_paths::get_cargo_toml_path(&rust_root);
+ let package_manifest = match rust_paths::get_package_manifest_path(&cargo_toml) {
+ Ok(path) => path,
+ Err(e) => {
+ eprintln!("Error: {}", e);
+ exit(1);
+ }
+ };
+
+ // Get token from CLI arg, then env vars
+ let token = get_arg("token")
+ .or_else(|| env::var("CARGO_REGISTRY_TOKEN").ok().filter(|s| !s.is_empty()))
+ .or_else(|| env::var("CARGO_TOKEN").ok().filter(|s| !s.is_empty()));
+
+ let package_info = match rust_paths::read_package_info(&package_manifest) {
+ Ok(info) => info,
+ Err(e) => {
+ eprintln!("Error: {}", e);
+ exit(1);
+ }
+ };
+ let name = package_info.name;
+ let version = package_info.version;
+
+ println!("Package: {}@{}", name, version);
+
+ if name == "example-sum-package-name" {
+ println!("Skipping publish: package name is the template default 'example-sum-package-name'");
+ println!("Rename the package in Cargo.toml before publishing to crates.io");
+ set_output("publish_result", "skipped");
+ return;
+ }
+
+ println!();
+ println!("=== Attempting to publish to crates.io ===");
+
+ if token.is_none() {
+ println!("::warning::Neither CARGO_REGISTRY_TOKEN nor CARGO_TOKEN is set, attempting publish without explicit token");
+ println!();
+ println!("To fix this, ensure one of the following secrets is configured:");
+ println!(" - CARGO_REGISTRY_TOKEN (Cargo's native env var, preferred)");
+ println!(" - CARGO_TOKEN (alternative for backwards compatibility)");
+ println!();
+ println!("For organization secrets, you may need to map the secret name in your workflow:");
+ println!(" env:");
+ println!(" CARGO_REGISTRY_TOKEN: ${{{{ secrets.CARGO_TOKEN }}}}");
+ println!();
+ } else {
+ println!("Using provided authentication token");
+ }
+
+ // Build the cargo publish command
+ let mut cmd = Command::new("cargo");
+ cmd.arg("publish").arg("--allow-dirty").arg("-p").arg(&name);
+
+ if let Some(t) = &token {
+ cmd.arg("--token").arg(t);
+ }
+
+ // For multi-language repos, change to the rust directory
+ if needs_cd(&rust_root) {
+ cmd.current_dir(&rust_root);
+ }
+
+ let output = cmd.output().expect("Failed to execute cargo publish");
+
+ if output.status.success() {
+ println!("Successfully published {}@{} to crates.io", name, version);
+ set_output("publish_result", "success");
+ } else {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let combined = format!("{}\n{}", stdout, stderr);
+
+ let kind = classify_failure(&combined);
+ match kind {
+ FailureKind::AlreadyExists => {
+ eprintln!();
+ eprintln!("=== VERSION ALREADY PUBLISHED ===");
+ eprintln!();
+ eprintln!("Version {} already exists on crates.io.", version);
+ eprintln!("The release pipeline must always publish a version greater than what is already published.");
+ eprintln!("This indicates a bug in version bumping: the pipeline should have computed a new, unpublished version.");
+ eprintln!();
+ }
+ FailureKind::RateLimited => {
+ eprintln!();
+ eprintln!("=== CRATES.IO RATE LIMIT (HTTP 429) ===");
+ eprintln!();
+ eprintln!("crates.io rejected the publish because too many versions of this");
+ eprintln!("crate have been published in the last 24 hours.");
+ eprintln!();
+ eprintln!("Original cargo publish error:");
+ eprintln!("{}", combined.trim());
+ eprintln!();
+ eprintln!("This is a TRANSIENT, automatically-recoverable throttle, not a pipeline bug.");
+ eprintln!("No action is required other than waiting for the 24-hour window to roll over.");
+ eprintln!("scripts/check-release-needed.rs will re-attempt the same version on the next");
+ eprintln!("push to 'main' once the throttle window has cleared.");
+ eprintln!();
+ eprintln!("See: https://doc.rust-lang.org/cargo/reference/publishing.html#publishing-a-new-version-of-an-existing-crate");
+ eprintln!();
+ }
+ FailureKind::AuthFailed => {
+ eprintln!();
+ eprintln!("=== AUTHENTICATION FAILURE ===");
+ eprintln!();
+ eprintln!("Failed to publish due to missing or invalid authentication token.");
+ eprintln!();
+ eprintln!("SOLUTION: Configure one of these secrets in your repository or organization:");
+ eprintln!(" 1. CARGO_REGISTRY_TOKEN - Cargo's native environment variable (preferred)");
+ eprintln!(" 2. CARGO_TOKEN - Alternative name for backwards compatibility");
+ eprintln!();
+ eprintln!("If using organization secrets with a different name, map it in your workflow:");
+ eprintln!(" - name: Publish to Crates.io");
+ eprintln!(" env:");
+ eprintln!(" CARGO_REGISTRY_TOKEN: ${{{{ secrets.YOUR_SECRET_NAME }}}}");
+ eprintln!();
+ eprintln!("See: https://doc.rust-lang.org/cargo/reference/publishing.html");
+ eprintln!();
+ }
+ FailureKind::Unknown => {
+ eprintln!("Failed to publish for unknown reason");
+ eprintln!("{}", combined);
+ }
+ }
+
+ set_output("publish_result", kind.output_value());
+
+ // A rate-limit is a deferred, automatically-recoverable outcome: exit
+ // successfully so the release job does not go red over a transient
+ // crates.io throttle. Downstream release-artifact steps are gated on a
+ // successful publish (see .github/workflows/release.yml), so a deferred
+ // upload never produces partial Docker/GitHub release artifacts.
+ if kind.is_deferred() {
+ return;
+ }
+
+ exit(1);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn classifies_already_exists_response() {
+ let body = "error: crate version 0.1.0 is already uploaded";
+ assert_eq!(classify_failure(body), FailureKind::AlreadyExists);
+ assert_eq!(FailureKind::AlreadyExists.output_value(), "already_exists");
+ }
+
+ #[test]
+ fn classifies_rate_limit_response() {
+ let body = "\
+error: failed to publish my-crate v0.42.0 to registry at https://crates.io
+
+Caused by:
+ the remote server responded with an error (status 429 Too Many Requests): \
+You have published too many versions of this crate in the last 24 hours
+";
+ assert_eq!(classify_failure(body), FailureKind::RateLimited);
+ assert_eq!(FailureKind::RateLimited.output_value(), "rate_limited");
+ }
+
+ #[test]
+ fn classifies_rate_limit_from_too_many_versions_marker() {
+ let body = "the remote server responded: You have published too many versions";
+ assert_eq!(classify_failure(body), FailureKind::RateLimited);
+ }
+
+ #[test]
+ fn classifies_auth_failure_response() {
+ let body = "error: failed to publish: please provide a non-empty token";
+ assert_eq!(classify_failure(body), FailureKind::AuthFailed);
+ assert_eq!(FailureKind::AuthFailed.output_value(), "auth_failed");
+ }
+
+ #[test]
+ fn classifies_unknown_response() {
+ let body = "error: some brand new failure mode nobody has seen before";
+ assert_eq!(classify_failure(body), FailureKind::Unknown);
+ assert_eq!(FailureKind::Unknown.output_value(), "failed");
+ }
+
+ #[test]
+ fn rate_limit_takes_precedence_over_auth_markers() {
+ // A 429 body that also happens to mention "authentication" must still be
+ // classified as rate-limited, since the throttle is the actionable cause.
+ let body = "status 429 Too Many Requests: authentication retry later";
+ assert_eq!(classify_failure(body), FailureKind::RateLimited);
+ }
+
+ #[test]
+ fn only_rate_limit_is_deferred() {
+ // A rate-limit is the single deferred (exit-0) outcome; every other
+ // failure must remain a hard, non-zero error.
+ assert!(FailureKind::RateLimited.is_deferred());
+ assert!(!FailureKind::AlreadyExists.is_deferred());
+ assert!(!FailureKind::AuthFailed.is_deferred());
+ assert!(!FailureKind::Unknown.is_deferred());
+ }
+}
diff --git a/dev/log/issues/298/pulls/299/templates/rust-smoke-test-published-crate.rs b/dev/log/issues/298/pulls/299/templates/rust-smoke-test-published-crate.rs
new file mode 100644
index 00000000..cf38805d
--- /dev/null
+++ b/dev/log/issues/298/pulls/299/templates/rust-smoke-test-published-crate.rs
@@ -0,0 +1,578 @@
+#!/usr/bin/env rust-script
+//! Install-from-package smoke test for a published crates.io artifact.
+//!
+//! This script proves that the freshly published crate is usable by downstream
+//! consumers, not just visible in the crates.io index:
+//! - installs advertised binary targets with `cargo install` into a temp root
+//! - runs each installed binary with `--help`
+//! - compiles a fresh dependent crate that imports the published library target
+//!
+//! CLI output is captured before previewing a few lines, so the smoke test never
+//! pipes a live Rust process into a short reader such as `head` under `pipefail`.
+//!
+//! Usage:
+//! rust-script scripts/smoke-test-published-crate.rs --release-version
+//!
+//! Optional arguments:
+//! --crate-name Crate name. Defaults to Cargo.toml package name.
+//! --rust-root Root containing Cargo.toml. Defaults to auto-detect.
+//! --max-attempts Defaults to 5.
+//! --sleep-seconds Defaults to 10.
+//!
+//! Outputs (written to GITHUB_OUTPUT):
+//! - smoke_test: 'pass', or 'skipped' for template defaults
+//!
+//! ```cargo
+//! [dependencies]
+//! regex = "1"
+//! ```
+
+use regex::Regex;
+use std::env;
+use std::fs;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::process::{exit, Command, Output};
+use std::thread;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+#[path = "rust-paths.rs"]
+mod rust_paths;
+
+const TEMPLATE_DEFAULT_CRATE: &str = "example-sum-package-name";
+const DEFAULT_MAX_ATTEMPTS: u64 = 5;
+const DEFAULT_SLEEP_SECONDS: u64 = 10;
+const CLI_PREVIEW_LINES: usize = 20;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct EntryPoints {
+ crate_name: String,
+ version: String,
+ lib_name: Option,
+ bin_names: Vec,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ManifestSection {
+ Lib,
+ Bin,
+ Other,
+}
+
+fn get_arg(name: &str) -> Option {
+ let args: Vec = env::args().collect();
+ let flag = format!("--{name}");
+
+ if let Some(idx) = args.iter().position(|a| a == &flag) {
+ return args.get(idx + 1).cloned();
+ }
+
+ let env_name = name.to_uppercase().replace('-', "_");
+ env::var(&env_name).ok().filter(|s| !s.is_empty())
+}
+
+fn parse_count_arg(name: &str, default: u64) -> u64 {
+ get_arg(name)
+ .and_then(|value| {
+ value.parse::().map_or_else(
+ |_| {
+ eprintln!("Warning: Invalid {name} value '{value}'; using default {default}");
+ None
+ },
+ Some,
+ )
+ })
+ .unwrap_or(default)
+}
+
+fn set_output(key: &str, value: &str) {
+ if let Ok(output_file) = env::var("GITHUB_OUTPUT") {
+ if let Err(e) = fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&output_file)
+ .and_then(|mut f| writeln!(f, "{key}={value}"))
+ {
+ eprintln!("Warning: Could not write to GITHUB_OUTPUT: {e}");
+ }
+ }
+ println!("Output: {key}={value}");
+}
+
+fn should_skip_smoke_test(crate_name: &str) -> bool {
+ crate_name == TEMPLATE_DEFAULT_CRATE
+}
+
+fn default_lib_name(package_name: &str) -> String {
+ package_name.replace('-', "_")
+}
+
+fn manifest_value(line: &str, key: &str) -> Option {
+ let re = Regex::new(&format!(r#"^\s*{}\s*=\s*"([^"]+)""#, regex::escape(key))).unwrap();
+ re.captures(line)
+ .and_then(|caps| caps.get(1).map(|value| value.as_str().to_string()))
+}
+
+fn detect_entrypoints(
+ manifest_path: &Path,
+ manifest_content: &str,
+ crate_name: String,
+ version: String,
+) -> EntryPoints {
+ let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
+ let mut section = ManifestSection::Other;
+ let mut explicit_lib_name = None;
+ let mut bin_names = Vec::new();
+
+ for line in manifest_content.lines() {
+ let trimmed = line.trim();
+
+ if trimmed == "[lib]" {
+ section = ManifestSection::Lib;
+ continue;
+ }
+ if trimmed == "[[bin]]" {
+ section = ManifestSection::Bin;
+ continue;
+ }
+ if trimmed.starts_with('[') {
+ section = ManifestSection::Other;
+ continue;
+ }
+
+ let Some(name) = manifest_value(line, "name") else {
+ continue;
+ };
+ match section {
+ ManifestSection::Lib => explicit_lib_name = Some(name),
+ ManifestSection::Bin => bin_names.push(name),
+ ManifestSection::Other => {}
+ }
+ }
+
+ let has_library = explicit_lib_name.is_some() || manifest_dir.join("src/lib.rs").exists();
+ let lib_name =
+ has_library.then(|| explicit_lib_name.unwrap_or_else(|| default_lib_name(&crate_name)));
+
+ if bin_names.is_empty() && manifest_dir.join("src/main.rs").exists() {
+ bin_names.push(crate_name.clone());
+ }
+
+ EntryPoints {
+ crate_name,
+ version,
+ lib_name,
+ bin_names,
+ }
+}
+
+fn read_entrypoints(
+ package_manifest: &Path,
+ crate_name_override: Option,
+) -> Result {
+ let package_info = rust_paths::read_package_info(package_manifest)?;
+ let manifest_content = fs::read_to_string(package_manifest)
+ .map_err(|e| format!("Failed to read {}: {e}", package_manifest.display()))?;
+ let crate_name = crate_name_override.unwrap_or(package_info.name);
+ let version = get_arg("release-version").unwrap_or(package_info.version);
+
+ Ok(detect_entrypoints(
+ package_manifest,
+ &manifest_content,
+ crate_name,
+ version,
+ ))
+}
+
+fn output_text(output: &Output) -> String {
+ let mut text = String::new();
+ text.push_str(&String::from_utf8_lossy(&output.stdout));
+ if !output.stderr.is_empty() {
+ if !text.is_empty() {
+ text.push('\n');
+ }
+ text.push_str(&String::from_utf8_lossy(&output.stderr));
+ }
+ text
+}
+
+fn preview_lines(text: &str, max_lines: usize) -> String {
+ text.lines().take(max_lines).collect::>().join("\n")
+}
+
+fn run_status(command: &mut Command, label: &str) -> Result<(), String> {
+ println!("Running: {command:?}");
+ let status = command
+ .status()
+ .map_err(|e| format!("Failed to start {label}: {e}"))?;
+
+ if status.success() {
+ Ok(())
+ } else {
+ Err(format!("{label} failed with status {status}"))
+ }
+}
+
+fn retry(
+ label: &str,
+ max_attempts: u64,
+ sleep_seconds: u64,
+ mut attempt: F,
+) -> Result<(), String>
+where
+ F: FnMut() -> Result<(), String>,
+{
+ for attempt_number in 1..=max_attempts {
+ println!("{label} (attempt {attempt_number}/{max_attempts})");
+ match attempt() {
+ Ok(()) => return Ok(()),
+ Err(e) if attempt_number < max_attempts => {
+ eprintln!("{label} failed: {e}");
+ eprintln!("Waiting {sleep_seconds}s before retrying...");
+ thread::sleep(Duration::from_secs(sleep_seconds));
+ }
+ Err(e) => return Err(format!("{label} failed after {max_attempts} attempts: {e}")),
+ }
+ }
+
+ unreachable!("retry loop always returns");
+}
+
+fn make_temp_dir(crate_name: &str) -> Result {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_err(|e| format!("System clock is before UNIX_EPOCH: {e}"))?
+ .as_nanos();
+ let dir = env::temp_dir().join(format!("published-crate-smoke-{crate_name}-{nanos}"));
+ fs::create_dir_all(&dir).map_err(|e| format!("Failed to create {}: {e}", dir.display()))?;
+ Ok(dir)
+}
+
+fn install_binaries(
+ entrypoints: &EntryPoints,
+ work_dir: &Path,
+ max_attempts: u64,
+ sleep_seconds: u64,
+) -> Result